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,2547 @@
1
+ """CodingSession — the single stateful runtime object for a persistent coding session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib.metadata
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ from collections.abc import AsyncIterator, Callable
13
+ from dataclasses import dataclass, replace
14
+ from pathlib import Path
15
+ from time import time
16
+ from typing import Any
17
+
18
+ from rlmagent.context.budget import (
19
+ DEFAULT_WINDOW,
20
+ ContextEstimate,
21
+ ContextLimits,
22
+ apply_compaction,
23
+ build_summary_prompts,
24
+ estimate_context,
25
+ exceeds_threshold,
26
+ plan_compaction,
27
+ resolve_window,
28
+ )
29
+ from rlmagent.agents.prompts import COMPACTION_NOTE, RESUMED_NOTICE
30
+ from rlmagent.directives import build_default_registry, parse_command
31
+ from rlmagent.instructions import PromptSection
32
+ from rlmagent.routing import ROUTED_PROVIDER, RoutePin, is_route_failure, pin_from_storage
33
+ from rlmagent.harness.contracts.stream import (
34
+ AgentEvent,
35
+ MessageEndEvent,
36
+ MessageStartEvent,
37
+ RetryEvent,
38
+ RunEndEvent,
39
+ )
40
+ from rlmagent.harness.contracts.tooling import ToolSpec
41
+ from rlmagent.harness.contracts.transcript import (
42
+ HumanEntry,
43
+ ModelEntry,
44
+ PruneSummaryEntry,
45
+ ShellResultEntry,
46
+ TranscriptEntry,
47
+ surface_text,
48
+ )
49
+ from rlmagent.harness.contracts.values import JValue
50
+ from rlmagent.harness.driver import (
51
+ PendingBatch,
52
+ QueueShiftEvent,
53
+ RuntimeConfig,
54
+ RuntimeHarness,
55
+ )
56
+ from rlmagent.harness.mending import HistoryMend, mend_tool_history
57
+ from rlmagent.harness.provider.base import ModelProvider
58
+ from rlmagent.harness.provider.wire import StreamCloseEvent
59
+ from rlmagent.harness.session.index import SessionCatalog
60
+ from rlmagent.harness.session.records import (
61
+ ExtensionRecord,
62
+ ForkSummaryRecord,
63
+ ModelSwapRecord,
64
+ PruneRecord,
65
+ ReasoningLevelRecord,
66
+ SessionMetaRecord,
67
+ SessionRecord,
68
+ TagRecord,
69
+ TipRecord,
70
+ TranscriptRecord,
71
+ mint_id,
72
+ )
73
+ from rlmagent.harness.session.replay import (
74
+ TreeIntegrityError,
75
+ find_tip,
76
+ project_active_branch,
77
+ project_records,
78
+ trace_to_entry,
79
+ )
80
+ from rlmagent.harness.session.store import JsonlVault
81
+
82
+ # ── context-window budget ──────────────────────────────────────────────────
83
+
84
+ #: Context accounting and compaction policy live in ``rlmagent.context.budget``.
85
+ #: The session owns one immutable ``ContextLimits`` and delegates every estimate,
86
+ #: threshold check, and compaction plan to that module.
87
+ _CONTEXT_LIMITS = ContextLimits()
88
+
89
+ #: The single slash-command catalog. Names, aliases, descriptions, and usage
90
+ #: strings all come from here; the session binds its own handlers to them.
91
+ COMMAND_REGISTRY = build_default_registry()
92
+
93
+ #: Registry commands a coding session dispatches, plus the ones a frontend runs
94
+ #: on its behalf: ``/continue`` resumes the loop (which yields events, not a
95
+ #: string), and ``/quit`` and ``/version`` belong to the frontend entirely.
96
+ #: They are listed here so completion and ``/help`` describe everything a user
97
+ #: can actually type. Anything outside this set stays in the registry for other
98
+ #: callers but is never offered here.
99
+ _RUNNABLE_COMMANDS: frozenset[str] = frozenset({
100
+ "branch", "branches", "compact", "continue", "create-skill", "diag", "diff", "tasks", "mcp",
101
+ "goal", "autonomous", "btw", "image",
102
+ "export", "help", "model", "name", "plan", "provider", "quit", "reload",
103
+ "refine", "rewind", "session", "shell", "skills", "stats", "system", "think", "tools", "undo",
104
+ "version", "tier", "fast", "update", "share", "logs", "changelog",
105
+ })
106
+
107
+ _RUNNABLE_COMMAND_TABLE: tuple[tuple[str, str], ...] = tuple(
108
+ (command.name, command.description)
109
+ for command in COMMAND_REGISTRY.all_commands()
110
+ if command.name in _RUNNABLE_COMMANDS
111
+ )
112
+
113
+ _EXTENSION_GROUP = "rlm-agent.extensions"
114
+
115
+
116
+ _EXPORT_FORMATS = ("text", "json", "jsonl", "html")
117
+ _EXPORT_SUFFIX = {"text": "txt", "json": "json", "jsonl": "jsonl", "html": "html"}
118
+
119
+
120
+ def parse_export_arg(arg: str) -> tuple[str, str | None]:
121
+ """Split ``/export`` arguments into ``(format, destination)``."""
122
+ parts = arg.split()
123
+ if not parts:
124
+ return "text", None
125
+
126
+ if parts[0].lower() in _EXPORT_FORMATS:
127
+ fmt = parts[0].lower()
128
+ rest = " ".join(parts[1:]).strip()
129
+ return fmt, rest or None
130
+
131
+ destination = arg.strip()
132
+
133
+ # A lone bare word with no separator and no extension is a mistyped
134
+ # format, not a path — returning it as the format lets the caller reject
135
+ # it instead of silently creating a junk file.
136
+ if (
137
+ len(parts) == 1
138
+ and not Path(destination).suffix
139
+ and not any(sep in destination for sep in ("/", "\\", "~", "."))
140
+ ):
141
+ return destination.lower(), None
142
+
143
+ # Otherwise infer the format from the file extension.
144
+ suffix = Path(destination).suffix.lstrip(".").lower()
145
+ for name, ext in _EXPORT_SUFFIX.items():
146
+ if suffix == ext:
147
+ return name, destination
148
+ return "text", destination
149
+
150
+
151
+ _SKILL_NAME_RE = re.compile(r"\A[a-z0-9][a-z0-9._-]*\Z", re.IGNORECASE)
152
+ _SKILL_DESC_MAX = 100
153
+
154
+
155
+ def parse_create_skill_arg(arg: str) -> tuple[str, str]:
156
+ """Split ``/create-skill`` arguments into ``(name, body)``."""
157
+ stripped = arg.strip()
158
+ if not stripped:
159
+ return "", ""
160
+ parts = stripped.split(None, 1)
161
+ name = parts[0]
162
+ body = parts[1].strip() if len(parts) > 1 else ""
163
+ return name, body
164
+
165
+
166
+ def derive_skill_description(body: str) -> str:
167
+ """Use the first non-empty line of *body* as the skill description."""
168
+ for line in body.splitlines():
169
+ line = line.strip().lstrip("#").strip()
170
+ if line:
171
+ return line[:_SKILL_DESC_MAX]
172
+ return ""
173
+
174
+
175
+ def render_skill_file(name: str, description: str, body: str) -> str:
176
+ """Render a ``SKILL.md`` with front matter."""
177
+ return (
178
+ f"---\nname: {name}\ndescription: {description}\n---\n\n{body.rstrip()}\n"
179
+ )
180
+
181
+
182
+ _TITLE_MAX_CHARS = 48
183
+
184
+ def summarize_prompt(text: str, *, limit: int = _TITLE_MAX_CHARS) -> str:
185
+ """Condense a prompt into a one-line session title."""
186
+ flat = " ".join(text.split())
187
+ if flat.startswith("/"):
188
+ flat = flat.lstrip("/")
189
+ if not flat:
190
+ return ""
191
+ if len(flat) <= limit:
192
+ return flat
193
+ clipped = flat[:limit]
194
+ if " " in clipped:
195
+ clipped = clipped.rsplit(" ", 1)[0]
196
+ return clipped.rstrip(" ,.;:-") + "…"
197
+
198
+
199
+ # ── session statistics ─────────────────────────────────────────────────────
200
+
201
+
202
+ # Phrases providers use when a request exceeds the model's context window.
203
+ _OVERFLOW_PHRASES = (
204
+ "context length",
205
+ "context window",
206
+ "context limit",
207
+ "maximum context",
208
+ "max context",
209
+ "input is too long",
210
+ "input length",
211
+ "prompt is too long",
212
+ "too many tokens",
213
+ "token limit",
214
+ "exceeds the limit",
215
+ "exceeded the limit",
216
+ )
217
+
218
+
219
+ def _is_context_overflow(reply: ModelEntry) -> bool:
220
+ """Whether a failed reply reports that the prompt did not fit."""
221
+ if reply.stop_reason != "error":
222
+ return False
223
+ text = (reply.error_message or "").lower()
224
+ return any(phrase in text for phrase in _OVERFLOW_PHRASES)
225
+
226
+
227
+ _HISTORY_REPAIR_NAMESPACE = "rlm-agent.history-repair"
228
+ _SUB_AGENT_NAMESPACE = "rlm-agent.sub-agent"
229
+ _PLAYBOOK_NAMESPACE = "rlm-agent.playbook"
230
+
231
+
232
+ def _records_after(
233
+ records: list[SessionRecord], tip_id: str | None, ancestor_id: str | None,
234
+ ) -> list[SessionRecord]:
235
+ """Records on the active path strictly after ``ancestor_id``, root-first."""
236
+ if tip_id is None:
237
+ return []
238
+ try:
239
+ path = trace_to_entry(records, tip_id)
240
+ except TreeIntegrityError:
241
+ return []
242
+ ids = [record.id for record in path]
243
+ start = ids.index(ancestor_id) + 1 if ancestor_id in ids else 0
244
+ return path[start:]
245
+
246
+
247
+ @dataclass(frozen=True, slots=True)
248
+ class SessionTreeChoice:
249
+ """One branchable entry in the active session tree."""
250
+
251
+ entry_id: str
252
+ label: str
253
+ active: bool = False
254
+ is_tool_call: bool = False
255
+
256
+
257
+ def _short_preview(text: str, *, limit: int = 72) -> str:
258
+ """Collapse whitespace and clip *text* for single-line display."""
259
+ normalized = " ".join(text.split())
260
+ if len(normalized) <= limit:
261
+ return normalized or "(empty)"
262
+ return f"{normalized[: limit - 1]}..."
263
+
264
+
265
+ def _tree_entry_title(record: SessionRecord) -> str:
266
+ """Human-readable one-line title for a tree row."""
267
+ match record.type:
268
+ case "message":
269
+ message = record.message
270
+ if (
271
+ isinstance(message, ModelEntry)
272
+ and message.tool_calls
273
+ and not message.text.strip()
274
+ ):
275
+ names = ", ".join(call.name for call in message.tool_calls)
276
+ return f"tool call: {names}"
277
+ return f"{message.role}: {_short_preview(surface_text(message))}"
278
+ case "compaction":
279
+ return f"compaction summary: {_short_preview(record.summary)}"
280
+ case "branch_summary":
281
+ return f"branch summary: {_short_preview(record.summary)}"
282
+ case _:
283
+ return record.type
284
+
285
+
286
+ def _is_branchable_record(record: SessionRecord) -> bool:
287
+ """Whether a rewind/branch may target *record*."""
288
+ if record.type in {"compaction", "branch_summary"}:
289
+ return True
290
+ if record.type != "message":
291
+ return False
292
+ return isinstance(record.message, HumanEntry | ModelEntry)
293
+
294
+
295
+ def _is_tool_call_record(record: SessionRecord) -> bool:
296
+ """Whether *record* holds an assistant turn that requested tools."""
297
+ return (
298
+ record.type == "message"
299
+ and isinstance(record.message, ModelEntry)
300
+ and bool(record.message.tool_calls)
301
+ )
302
+
303
+
304
+ def _ordered_tree_records(records: list[SessionRecord]) -> tuple[SessionRecord, ...]:
305
+ """Depth-first ordering of the record tree, roots first."""
306
+ children: dict[str | None, list[SessionRecord]] = {}
307
+ for record in records:
308
+ if record.type != "leaf":
309
+ children.setdefault(record.parent_id, []).append(record)
310
+
311
+ ordered: list[SessionRecord] = []
312
+ seen: set[str] = set()
313
+ expanded: set[str | None] = set()
314
+
315
+ def walk(root_parent_id: str | None) -> None:
316
+ stack: list[str | None] = [root_parent_id]
317
+ while stack:
318
+ parent_id = stack.pop()
319
+ if parent_id in expanded:
320
+ continue
321
+ expanded.add(parent_id)
322
+ kids = children.get(parent_id, [])
323
+ for child in kids:
324
+ if child.id not in seen:
325
+ ordered.append(child)
326
+ seen.add(child.id)
327
+ for child in reversed(kids):
328
+ stack.append(child.id)
329
+
330
+ walk(None)
331
+ for record in records:
332
+ if record.type != "leaf" and record.id not in seen:
333
+ ordered.append(record)
334
+ seen.add(record.id)
335
+ walk(record.id)
336
+ return tuple(ordered)
337
+
338
+
339
+ def _tree_branch_indents(records: list[SessionRecord]) -> dict[str, int]:
340
+ """Indent depth per record: only forks (2nd+ sibling) add a level."""
341
+ children: dict[str | None, list[str]] = {}
342
+ for record in records:
343
+ if record.type != "leaf":
344
+ children.setdefault(record.parent_id, []).append(record.id)
345
+
346
+ sibling_index = {
347
+ child_id: index
348
+ for kids in children.values()
349
+ for index, child_id in enumerate(kids)
350
+ }
351
+ indents: dict[str, int] = {}
352
+ for record in records:
353
+ if record.type == "leaf":
354
+ continue
355
+ parent_indent = (
356
+ indents.get(record.parent_id, 0) if record.parent_id is not None else 0
357
+ )
358
+ indents[record.id] = parent_indent + (
359
+ 1 if sibling_index.get(record.id, 0) > 0 else 0
360
+ )
361
+ return indents
362
+
363
+
364
+ def _tree_choice_label(record: SessionRecord, *, branch_indent: int = 0) -> str:
365
+ """Indented display label for one tree row."""
366
+ return f"{' ' * branch_indent}{_tree_entry_title(record)}"
367
+
368
+
369
+ @dataclass(frozen=True, slots=True)
370
+ class SessionStats:
371
+ """Cumulative usage statistics for a coding session."""
372
+
373
+ turn_count: int = 0
374
+ message_count: int = 0
375
+ total_input_tokens: int = 0
376
+ total_output_tokens: int = 0
377
+ total_cost_usd: float = 0.0
378
+ estimated_context_tokens: int = 0
379
+ context_limit: int = DEFAULT_WINDOW
380
+ cache_read_tokens: int = 0
381
+ cache_write_tokens: int = 0
382
+ # Prompt size and cached share of the most recent model request.
383
+ latest_prompt_tokens: int = 0
384
+ latest_cache_read_tokens: int = 0
385
+
386
+ @property
387
+ def cache_hit_rate(self) -> float | None:
388
+ """Share of all prompt tokens this session served from the cache."""
389
+ if not self._cache_active or self.total_input_tokens <= 0:
390
+ return None
391
+ return self.cache_read_tokens / self.total_input_tokens
392
+
393
+ @property
394
+ def latest_cache_hit_rate(self) -> float | None:
395
+ """Share of the latest request's prompt served from the cache."""
396
+ if not self._cache_active or self.latest_prompt_tokens <= 0:
397
+ return None
398
+ return self.latest_cache_read_tokens / self.latest_prompt_tokens
399
+
400
+ @property
401
+ def _cache_active(self) -> bool:
402
+ return self.cache_read_tokens > 0 or self.cache_write_tokens > 0
403
+
404
+
405
+ # ── main class ─────────────────────────────────────────────────────────────
406
+
407
+
408
+ MAX_SAVED_UNDO = 50
409
+ MAX_SAVED_UNDO_STATES = 5
410
+
411
+
412
+ class CodingSession:
413
+ """Single stateful runtime object for a persistent, resumable coding session."""
414
+
415
+ def __init__(
416
+ self,
417
+ *,
418
+ session_id: str,
419
+ vault: JsonlVault | None,
420
+ catalog: SessionCatalog | None = None,
421
+ provider: ModelProvider,
422
+ provider_name: str,
423
+ model: str,
424
+ system: str,
425
+ tools: list[ToolSpec],
426
+ transcript: list[TranscriptEntry] | None = None,
427
+ record_ids: list[str] | None = None,
428
+ tip_id: str | None = None,
429
+ title: str | None = None,
430
+ thinking_level: str | None = None,
431
+ cwd: str | None = None,
432
+ tools_loader: Callable[[], list[ToolSpec]] | None = None,
433
+ skills_loader: Callable[[], list[Any]] | None = None,
434
+ templates_loader: Callable[[], list[Any]] | None = None,
435
+ ) -> None:
436
+ self._session_id = session_id
437
+ self._vault = vault
438
+ from rlmagent.steering import Steering
439
+
440
+ self.steering = Steering(vault.path.with_suffix(".steering.json") if vault else None)
441
+ self._catalog = catalog
442
+ self._pending_header: SessionMetaRecord | None = None
443
+ self._provider = provider
444
+ self._provider_name = provider_name
445
+ self._model = model
446
+ self._system = system
447
+ self._title = title
448
+ self._thinking_level = thinking_level
449
+ self._cwd = cwd or os.getcwd()
450
+ self._record_ids: list[str] = list(record_ids) if record_ids else []
451
+ # The tip is the head of the active branch. It is not always the last
452
+ # transcript record — metadata records (title, model, prune) also chain
453
+ # onto it — so callers pass it explicitly when known.
454
+ self._tip_id: str | None = tip_id or (
455
+ self._record_ids[-1] if self._record_ids else None
456
+ )
457
+ self._extensions: list[object] = []
458
+ self._named = title is not None
459
+ self.shadow = None
460
+ self._undo_stack: list[tuple[str, str | None, list, dict | None]] = []
461
+ self._renamed_manually = False
462
+ self._tools_loader = tools_loader
463
+ self._skills_loader = skills_loader
464
+ self._skills: list[Any] = list(skills_loader() if skills_loader else [])
465
+ self._templates_loader = templates_loader
466
+ self._templates: list[Any] = list(
467
+ templates_loader() if templates_loader else []
468
+ )
469
+ # Unrestricted tool set. Plan mode narrows what the harness sees, so
470
+ # the full list is kept here to restore from and to hand to new
471
+ # sessions.
472
+ self._all_tools: list[ToolSpec] = list(tools)
473
+ self._plan_mode = False
474
+ # Backend pin for routed providers; see ``rlmagent.routing``.
475
+ self._route = RoutePin()
476
+
477
+ # Cumulative stats
478
+ self._turn_count = 0
479
+ self._total_input = 0
480
+ self._total_output = 0
481
+ self._total_cost = 0.0
482
+ self._cache_read = 0
483
+ self._cache_write = 0
484
+ self._latest_prompt = 0
485
+ self._latest_cache_read = 0
486
+
487
+ # Build harness
488
+ config = RuntimeConfig(
489
+ provider=provider,
490
+ model=model,
491
+ system=system,
492
+ tools=list(tools),
493
+ cache_key=session_id,
494
+ )
495
+ self._harness = RuntimeHarness(config, messages=transcript or [])
496
+
497
+
498
+ self._unsubscribe = self._harness.on_event(self._on_harness_event)
499
+
500
+ # A resumed session restores its thinking level from the record log;
501
+ # apply it so the first request matches what the transcript claims.
502
+ self._apply_thinking_level()
503
+
504
+
505
+ if transcript:
506
+ for entry in transcript:
507
+ if isinstance(entry, ModelEntry):
508
+ self._tally(entry)
509
+
510
+
511
+
512
+ @classmethod
513
+ async def create(
514
+ cls,
515
+ *,
516
+ provider: ModelProvider,
517
+ provider_name: str,
518
+ model: str,
519
+ system: str,
520
+ tools: list[ToolSpec] | None = None,
521
+ sessions_dir: Path | None = None,
522
+ session_id: str | None = None,
523
+ cwd: str | None = None,
524
+ tools_loader: Callable[[], list[ToolSpec]] | None = None,
525
+ skills_loader: Callable[[], list[Any]] | None = None,
526
+ templates_loader: Callable[[], list[Any]] | None = None,
527
+ ) -> CodingSession:
528
+ """Create a new coding session with optional persistence."""
529
+ sid = session_id or mint_id()[:12]
530
+ vault: JsonlVault | None = None
531
+ catalog: SessionCatalog | None = None
532
+ root_id: str | None = None
533
+ header: SessionMetaRecord | None = None
534
+ effective_cwd = cwd or os.getcwd()
535
+ if sessions_dir is not None:
536
+ vault = JsonlVault(sessions_dir / f"{sid}.jsonl")
537
+ # The metadata record is the tree root; everything else descends
538
+ # from it so a parent-chain walk always reaches session cwd/title.
539
+ # Nothing is written until the first record, so a session that is
540
+ # opened and left never shows up as an empty resumable one.
541
+ header = SessionMetaRecord(cwd=effective_cwd)
542
+ root_id = header.id
543
+ catalog = SessionCatalog(sessions_dir)
544
+ session = cls(
545
+ session_id=sid,
546
+ vault=vault,
547
+ catalog=catalog,
548
+ provider=provider,
549
+ provider_name=provider_name,
550
+ model=model,
551
+ system=system,
552
+ tools=tools or [],
553
+ tip_id=root_id,
554
+ cwd=cwd,
555
+ tools_loader=tools_loader,
556
+ skills_loader=skills_loader,
557
+ templates_loader=templates_loader,
558
+ )
559
+ if vault is not None:
560
+ session._pending_header = header
561
+ return session
562
+
563
+ @classmethod
564
+ async def resume(
565
+ cls,
566
+ session_id: str,
567
+ *,
568
+ provider: ModelProvider,
569
+ provider_name: str,
570
+ model: str,
571
+ system: str,
572
+ tools: list[ToolSpec] | None = None,
573
+ sessions_dir: Path,
574
+ tools_loader: Callable[[], list[ToolSpec]] | None = None,
575
+ skills_loader: Callable[[], list[Any]] | None = None,
576
+ templates_loader: Callable[[], list[Any]] | None = None,
577
+ keep_model: bool = False,
578
+ ) -> CodingSession:
579
+ """Load an existing session from persistent storage."""
580
+ vault = JsonlVault(sessions_dir / f"{session_id}.jsonl")
581
+ if not vault.path.exists():
582
+ raise FileNotFoundError(f"Session not found: {session_id}")
583
+
584
+ records = await vault.read_all()
585
+ # Reconstruct along the active-leaf path, not raw write order: once a
586
+ # session has branched, the file also holds records from abandoned
587
+ # branches. Walking parent pointers from the tip keeps those out, and
588
+ # a damaged chain degrades to a flat replay rather than failing to load.
589
+ state = project_active_branch(records)
590
+ tip_id = find_tip(records)
591
+
592
+ catalog = SessionCatalog(sessions_dir)
593
+ catalog.touch(session_id)
594
+ meta = catalog.get(session_id)
595
+ # The recorded model belongs to the provider the session last used.
596
+ # Under a different provider it would be rejected, so keep the
597
+ # active provider's model; an explicit choice always wins.
598
+ stored = meta.provider if meta is not None else None
599
+ if not keep_model and (stored is None or stored == provider_name):
600
+ # The starting model lives in the index; later switches in the log.
601
+ model = state.model or (meta.model if meta is not None else None) or model
602
+
603
+ session = cls(
604
+ session_id=session_id,
605
+ vault=vault,
606
+ catalog=catalog,
607
+ provider=provider,
608
+ provider_name=provider_name,
609
+ model=model,
610
+ system=system,
611
+ tools=tools or [],
612
+ transcript=state.transcript,
613
+ record_ids=state.record_ids,
614
+ tip_id=tip_id,
615
+ title=state.title,
616
+ thinking_level=state.thinking_level,
617
+ cwd=state.cwd,
618
+ tools_loader=tools_loader,
619
+ skills_loader=skills_loader,
620
+ templates_loader=templates_loader,
621
+ )
622
+ if meta is not None:
623
+ session._route = pin_from_storage(
624
+ meta.inference_provider, meta.inference_provider_mode,
625
+ )
626
+ session._apply_route()
627
+ await session._mend_stored_history()
628
+ # A kernel cannot be saved, so a resumed session starts a fresh one.
629
+ if not getattr(provider, "is_backend", False):
630
+ session.inject(RESUMED_NOTICE)
631
+ return session
632
+
633
+ # ── run lifecycle ──────────────────────────────────────────────────
634
+
635
+ def submit(self, text: str) -> AsyncIterator[AgentEvent]:
636
+ """Submit a user prompt and stream agent events."""
637
+ refinement = getattr(self, "refinement", None)
638
+ if refinement is not None:
639
+ refinement.at_boundary()
640
+ pilot = getattr(self, "autopilot", None)
641
+ if pilot is not None and (reminder := pilot.reminder()):
642
+ self.inject(reminder)
643
+ # Name the session from its first prompt straight away, so listings
644
+ # never show a bare id while waiting on the model-generated title.
645
+ if not self._title:
646
+ self.set_provisional_title(text)
647
+ # `/skill:<name>` and `/<template-name>` are expanded into their full
648
+ # text before reaching the model; the title keeps the short form.
649
+ # Skills are checked first: their `skill:` prefix cannot collide with a
650
+ # template name, so the cheaper, unambiguous match goes ahead.
651
+ prompt = self.expand_skill(text) or self.expand_template(text) or text
652
+ return self._turn(text, prompt)
653
+
654
+ async def _turn(self, text: str, prompt: str) -> AsyncIterator[AgentEvent]:
655
+ prompt += await self._before_turn(text)
656
+ images = getattr(self, "pending_images", None)
657
+ content = prompt
658
+ if images:
659
+ from rlmagent.harness.contracts.transcript import TextSegment
660
+
661
+ content = [TextSegment(text=prompt), *images]
662
+ self.pending_images = []
663
+ async for event in self._run_wrapped(self._harness.submit(content)):
664
+ yield event
665
+
666
+ async def _before_turn(self, text: str) -> str:
667
+ """Snapshot the workspace for /undo; preload the files and links the prompt names."""
668
+ if self.shadow is not None and self.shadow.available:
669
+ try:
670
+ commit = await asyncio.to_thread(self.shadow.snapshot, f"before: {text[:80]}")
671
+ backend = getattr(self, "backend", None)
672
+ state = backend.checkpoint_state() if backend is not None else None
673
+ self._undo_stack.append(
674
+ (commit, self._tip_id, list(self._harness.transcript), state))
675
+ self._save_undo()
676
+ self._undo_problem = None
677
+ except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
678
+ self._undo_problem = str(exc) or type(exc).__name__
679
+ print(f"rlm-agent: /undo will not cover this turn: {self._undo_problem}",
680
+ file=sys.stderr)
681
+ tree = getattr(self, "agent_tree", None)
682
+ if tree is None or not self.cwd:
683
+ return ""
684
+ from rlmagent.prompt_refs import note, references
685
+
686
+ refs = references(text, self.cwd)
687
+ backend = getattr(self, "backend", None)
688
+ if backend is not None:
689
+ backend.prompt_context = refs
690
+ return note(refs)
691
+ # What the root works from; the delegation guard compares sub-agent contexts to it.
692
+ tree.nodes[tree.root_id()].context = refs if refs["files"] else text
693
+ if not refs["files"] and not refs["links"]:
694
+ return ""
695
+ literal = json.dumps(json.dumps(refs))
696
+ await tree.root_kernel().run(f"import json as _j\nPROMPT = _j.loads({literal})\ndel _j")
697
+ return note(refs)
698
+
699
+ def _undo_file(self) -> Path | None:
700
+ vault = getattr(self, "_vault", None)
701
+ return vault.path.with_suffix(".undo.json") if vault is not None else None
702
+
703
+ def _save_undo(self) -> None:
704
+ """Keep the turns /undo can reach beside the session, so they survive a restart."""
705
+ path = self._undo_file()
706
+ if path is None:
707
+ return
708
+ kept = self._undo_stack[-MAX_SAVED_UNDO:]
709
+ entries = [{"commit": commit, "tip": tip,
710
+ # Variables can be large: only the latest turns keep theirs.
711
+ "state": state if i >= len(kept) - MAX_SAVED_UNDO_STATES else None}
712
+ for i, (commit, tip, _messages, state) in enumerate(kept)]
713
+ data = json.dumps({"cwd": self.cwd, "entries": entries}, default=str)
714
+ staged = path.with_name(path.name + ".tmp")
715
+ try:
716
+ # The first turn of a new session can come before its folder exists.
717
+ staged.parent.mkdir(parents=True, exist_ok=True)
718
+ staged.write_text(data, encoding="utf-8")
719
+ staged.replace(path)
720
+ except OSError as exc:
721
+ print(f"rlm-agent: /undo will not survive a restart: {exc}", file=sys.stderr)
722
+
723
+ def load_undo(self) -> None:
724
+ """Bring back the turns saved for /undo, if they belong to this folder."""
725
+ path = self._undo_file()
726
+ try:
727
+ saved = json.loads(path.read_text(encoding="utf-8")) if path is not None else None
728
+ except (OSError, ValueError):
729
+ return
730
+ if not saved or saved.get("cwd") != self.cwd:
731
+ return
732
+ # A turn with no tip began an empty conversation, so going back means no messages.
733
+ self._undo_stack = [(e["commit"], e.get("tip"), [], e.get("state"))
734
+ for e in saved.get("entries", [])]
735
+
736
+ @property
737
+ def interrupted(self) -> bool:
738
+ """The saved conversation stops mid-turn: the app quit or crashed, or the user stopped it."""
739
+ for entry in reversed(self._harness.transcript):
740
+ role = getattr(entry, "role", "")
741
+ if role == "assistant":
742
+ return entry.stop_reason in ("aborted", "toolUse")
743
+ if role in ("user", "toolResult"):
744
+ return True
745
+ return False
746
+
747
+ def _why_no_undo(self, what: str) -> str:
748
+ reason = getattr(self.shadow, "reason", "") if self.shadow is not None else ""
749
+ return reason.replace("Undo", what, 1) if reason else f"{what} needs git on PATH."
750
+
751
+ async def _cmd_diff(self, arg: str) -> str:
752
+ arg = arg.strip()
753
+ if arg not in ("", "last", "stat", "last stat"):
754
+ return "Usage: /diff [last | stat]"
755
+ if not self._undo_stack:
756
+ ready = self.shadow is not None and self.shadow.available
757
+ return "Nothing has changed yet." if ready else self._why_no_undo("Diff")
758
+ commit = self._undo_stack[-1 if arg.startswith("last") else 0][0]
759
+ try:
760
+ text = await asyncio.to_thread(self.shadow.diff, commit, arg.endswith("stat"))
761
+ except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
762
+ return f"Diff failed: {exc}"
763
+ if not text:
764
+ return "No changes in the project."
765
+ limit = 20_000
766
+ return text if len(text) <= limit else (
767
+ text[:limit] + f"\n... cut at {limit} characters; /diff stat lists every file")
768
+
769
+ async def _cmd_mcp(self, arg: str) -> str:
770
+ hub = getattr(self, "mcp", None)
771
+ if hub is None:
772
+ return "MCP is not available in this session."
773
+ name = arg.strip()
774
+ if name:
775
+ from rlmagent.mcp import McpError
776
+
777
+ action, _, server_name = name.partition(" ")
778
+ if action in ("login", "logout") and server_name.strip():
779
+ try:
780
+ operation = hub.login if action == "login" else hub.logout
781
+ return await operation(server_name.strip())
782
+ except McpError as exc:
783
+ return str(exc)
784
+ if name not in hub.servers:
785
+ return f"No MCP server named {name!r}."
786
+ try:
787
+ await hub.servers[name].start()
788
+ except McpError as exc:
789
+ return str(exc)
790
+ return hub.report()
791
+
792
+ def _cmd_image(self, arg: str) -> str:
793
+ """Attach an image file to the next message, for models that can see images."""
794
+ from rlmagent.tools.files import _MAX_IMAGE_BYTES, _read_image
795
+
796
+ raw = arg.strip().strip('"')
797
+ if not raw:
798
+ count = len(getattr(self, "pending_images", []) or [])
799
+ return f"{count} image(s) wait for your next message. Usage: /image PATH | clear"
800
+ if raw == "clear":
801
+ self.pending_images = []
802
+ return "Dropped the attached images."
803
+ path = Path(raw).expanduser()
804
+ if not path.is_absolute():
805
+ path = Path(self.cwd or ".") / path
806
+ if not path.is_file():
807
+ return f"No file at {path}."
808
+ if path.suffix.lower() not in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
809
+ return "Attach a PNG, JPEG, GIF or WebP image."
810
+ outcome = _read_image(path)
811
+ segment = outcome.content[0]
812
+ if getattr(segment, "type", "") != "image":
813
+ return segment.text
814
+ self.pending_images = [*(getattr(self, "pending_images", None) or []), segment]
815
+ size = path.stat().st_size / 1_000_000
816
+ return (f"Attached {path.name} ({size:.1f} MB, limit {_MAX_IMAGE_BYTES // 1_000_000} MB); "
817
+ "it goes with your next message.")
818
+
819
+ async def _cmd_btw(self, arg: str) -> str:
820
+ """Answer from the conversation so far; the exchange is not kept in it."""
821
+ question = arg.strip()
822
+ thread = self.__dict__.setdefault("_btw_thread", [])
823
+ if question.lower() in ("clear", "new"):
824
+ thread.clear()
825
+ return "Side thread cleared."
826
+ if not question:
827
+ return "Usage: /btw QUESTION (follow-ups remember earlier side questions; /btw clear)"
828
+ recent = "\n".join(f"[{getattr(m, 'role', '?')}] {getattr(m, 'text', '')}"
829
+ for m in list(self.transcript)[-30:])[-60_000:]
830
+ earlier = "".join(f"\nSide question: {q}\nAnswer: {a}\n" for q, a in thread[-6:])
831
+ prompt = (f"Conversation so far:\n{recent}\n"
832
+ + (f"\nEarlier side questions:{earlier}" if earlier else "")
833
+ + f"\nSide question: {question}")
834
+ system = ("Answer the user's side question briefly from the conversation. It is not "
835
+ "part of the task; do not suggest running tools.")
836
+ try:
837
+ answer = (await self._utility_completion(prompt, system)).strip() or "(no answer)"
838
+ except Exception as exc: # noqa: BLE001 - shown to the user, the session goes on
839
+ return f"Could not answer: {exc}"
840
+ thread.append((question, answer))
841
+ del thread[:-6]
842
+ return answer
843
+
844
+ async def _cmd_update(self, arg: str) -> str:
845
+ """Report the newest release; with ``apply``, install it in the background."""
846
+ import subprocess
847
+ import sys
848
+
849
+ from rlmagent.cli.main import get_version
850
+ from rlmagent.cli.update import select_release
851
+ from rlmagent.model.transport.client import fetch_json
852
+
853
+ installed = get_version()
854
+ if os.environ.get("RLM_AGENT_OFFLINE") == "1":
855
+ return f"rlm-agent {installed}. Update checks are off in offline mode."
856
+ try:
857
+ document = await asyncio.to_thread(
858
+ fetch_json, "https://pypi.org/pypi/rlmness-cli/json", timeout=15)
859
+ latest = select_release(document, "stable")
860
+ except Exception as exc: # noqa: BLE001 - reported, the session goes on
861
+ return f"Could not check for updates: {exc}"
862
+ if latest == installed:
863
+ return f"rlm-agent {installed} is the newest release."
864
+ if arg.strip().lower() != "apply":
865
+ return (f"rlm-agent {latest} is available (installed {installed}). "
866
+ "Run /update apply to install it, then restart.")
867
+ result = await asyncio.to_thread(
868
+ subprocess.run, [sys.executable, "-m", "rlmagent", "update", "--apply"],
869
+ capture_output=True, text=True, encoding="utf-8", errors="replace", check=False)
870
+ tail = "\n".join((result.stdout + result.stderr).strip().splitlines()[-8:])
871
+ if result.returncode != 0:
872
+ return f"Update failed (exit {result.returncode}):\n{tail}"
873
+ return f"Installed rlm-agent {latest}. Restart rlm-agent to use it.\n{tail}"
874
+
875
+ async def _cmd_share(self, arg: str) -> str:
876
+ """Preview the unlisted gist; publish only with the previewed SHA-256."""
877
+ from rlmagent.session_share import prepare, publish
878
+
879
+ words = arg.split()
880
+ body, digest = prepare(self.session_id, await self.export("html"),
881
+ await self.export("jsonl"))
882
+ if words[:1] != ["publish"]:
883
+ size = sum(len(f["content"]) for f in body["files"].values())
884
+ return (f"Unlisted gist preview for session {self.session_id}: "
885
+ f"{size:,} characters (session.html, session.jsonl). Nothing uploaded.\n"
886
+ f"SHA-256: {digest}\n"
887
+ "Anyone with the link can read it. To upload: /share publish <SHA-256>")
888
+ if len(words) != 2:
889
+ return "Use /share publish SHA256, with the digest the preview showed."
890
+ try:
891
+ url = await publish(body, expected_digest=words[1],
892
+ token=os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN"))
893
+ except ValueError as exc:
894
+ return str(exc)
895
+ return f"Shared: {url}"
896
+
897
+ def _cmd_logs(self, arg: str) -> str:
898
+ """The last lines of this session's newest log file."""
899
+ from rlmagent.logs import log_root
900
+
901
+ try:
902
+ count = max(1, min(int(arg.strip() or 40), 400))
903
+ except ValueError:
904
+ return "Use /logs [LINES]."
905
+ folder = log_root(self.cwd) / str(self.session_id)
906
+ files = sorted((p for p in folder.glob("*") if p.is_file()),
907
+ key=lambda p: p.stat().st_mtime) if folder.is_dir() else []
908
+ if not files:
909
+ return f"No logs yet for this session (looked in {folder})."
910
+ newest = files[-1]
911
+ try:
912
+ lines = newest.read_text(encoding="utf-8", errors="replace").splitlines()
913
+ except OSError as exc:
914
+ return f"Could not read {newest}: {exc}"
915
+ return f"{newest} (last {min(count, len(lines))} of {len(lines)} lines)\n" + "\n".join(lines[-count:])
916
+
917
+ async def _cmd_changelog(self, _arg: str) -> str:
918
+ """Recent releases from the package index, newest first."""
919
+ from rlmagent.cli.main import get_version
920
+ from rlmagent.model.transport.client import fetch_json
921
+
922
+ if os.environ.get("RLM_AGENT_OFFLINE") == "1":
923
+ return f"rlm-agent {get_version()}. The release list needs the network."
924
+ try:
925
+ document = await asyncio.to_thread(
926
+ fetch_json, "https://pypi.org/pypi/rlmness-cli/json", timeout=15)
927
+ except Exception as exc: # noqa: BLE001
928
+ return f"Could not fetch releases: {exc}"
929
+ rows = []
930
+ for version, files in (document.get("releases") or {}).items():
931
+ uploaded = min((f.get("upload_time", "") for f in files or []), default="")
932
+ if uploaded:
933
+ rows.append((uploaded[:10], version))
934
+ rows.sort(reverse=True)
935
+ installed = get_version()
936
+ lines = [f"{day} {version}{' (installed)' if version == installed else ''}"
937
+ for day, version in rows[:10]]
938
+ return "Recent releases:\n" + ("\n".join(lines) or "none listed")
939
+
940
+ def _cmd_goal(self, arg: str) -> str:
941
+ pilot = getattr(self, "autopilot", None)
942
+ return pilot.command_goal(arg) if pilot is not None else "Goals are not available here."
943
+
944
+ def _cmd_autonomous(self, arg: str) -> str:
945
+ pilot = getattr(self, "autopilot", None)
946
+ if pilot is None:
947
+ return "Autonomous mode is not available here."
948
+ return pilot.command_autonomous(arg)
949
+
950
+ def _cmd_tasks(self, arg: str) -> str:
951
+ tasks = getattr(self, "tasks", None)
952
+ if tasks is None:
953
+ return "No task list in this session."
954
+ action, _, value = arg.strip().partition(" ")
955
+ try:
956
+ if action in ("", "list"):
957
+ return tasks.render()
958
+ if action == "add":
959
+ tasks.add(value)
960
+ elif action in ("done", "undo", "remove"):
961
+ getattr(tasks, action)(value.strip() or 0)
962
+ elif action == "clear":
963
+ tasks.clear()
964
+ else:
965
+ return "Usage: /tasks [add TEXT | done N | undo N | remove N | clear]"
966
+ except ValueError as exc:
967
+ return str(exc)
968
+ return tasks.render()
969
+
970
+ async def _cmd_undo(self, arg: str) -> str:
971
+ if not self._undo_stack:
972
+ ready = self.shadow is not None and self.shadow.available
973
+ return "Nothing to undo." if ready else self._why_no_undo("Undo")
974
+ if getattr(self, "_undo_problem", None):
975
+ return (f"The last turn has no snapshot ({self._undo_problem}), so undo would "
976
+ "go back further than you expect. Nothing was changed.")
977
+ arg = arg.strip()
978
+ if arg == "list":
979
+ rows = []
980
+ for steps, entry in enumerate(reversed(self._undo_stack), start=1):
981
+ rows.append(f" {steps}. {await asyncio.to_thread(self.shadow.label, entry[0])}")
982
+ return "Turns you can go back to (/undo N):\n" + "\n".join(rows)
983
+ if arg and not arg.isdigit():
984
+ return "Usage: /undo [N | list]"
985
+ steps = int(arg or 1)
986
+ if not 1 <= steps <= len(self._undo_stack):
987
+ return f"Only {len(self._undo_stack)} turns can be undone."
988
+ commit, tip, transcript, state = self._undo_stack[-steps]
989
+ try:
990
+ changed = await asyncio.to_thread(self.shadow.restore, commit)
991
+ except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
992
+ return f"Undo failed; the conversation was not rewound. {exc}"
993
+ del self._undo_stack[-steps:]
994
+ self._save_undo()
995
+ if tip is not None and self._vault is not None:
996
+ await self.rewind(tip)
997
+ else:
998
+ self._harness.set_messages(transcript)
999
+ files = ", ".join(changed[:10]) + (" ..." if len(changed) > 10 else "")
1000
+ if state is not None:
1001
+ self.backend.restore_state(state)
1002
+ kernel = ("Saved variables were rolled back; effects outside the workspace were not."
1003
+ if state is not None else "Kernel variables are not rolled back.")
1004
+ skipped = sorted(self.shadow.skipped)
1005
+ missed = f" Not covered (over 5 MB): {', '.join(skipped[:5])}." if skipped else ""
1006
+ return (f"Undid {steps} turn{'s' if steps > 1 else ''}. Files restored: {files or 'none changed'}.{missed} {kernel}")
1007
+
1008
+ def set_provisional_title(self, text: str) -> None:
1009
+ """Derive a placeholder title from *text* and publish it immediately."""
1010
+ title = summarize_prompt(text)
1011
+ if not title:
1012
+ return
1013
+ self._title = title
1014
+ self._sync_title_to_catalog(title)
1015
+
1016
+ def resume_run(self) -> AsyncIterator[AgentEvent]:
1017
+ """Continue the agent loop without appending a new user message."""
1018
+ return self._run_wrapped(self._harness.resume())
1019
+
1020
+ def abort(self) -> None:
1021
+ """Cancel the currently running agent loop."""
1022
+ refinement = getattr(self, "refinement", None)
1023
+ if refinement is not None:
1024
+ refinement.cancel()
1025
+ improver = getattr(self, "improver", None)
1026
+ if improver is not None:
1027
+ improver.drop_pending()
1028
+ backend = getattr(self, "backend", None)
1029
+ if backend is not None:
1030
+ backend.cancel()
1031
+ self._harness.abort()
1032
+
1033
+ async def shutdown(self) -> None:
1034
+ """Flush pending state and release resources."""
1035
+ try:
1036
+ await self._shutdown()
1037
+ finally:
1038
+ lease = getattr(self, "_writer_lease", None)
1039
+ if lease is not None:
1040
+ lease.close()
1041
+ self._writer_lease = None
1042
+
1043
+ async def _shutdown(self) -> None:
1044
+ refinement = getattr(self, "refinement", None)
1045
+ if refinement is not None:
1046
+ await refinement.close()
1047
+ backend = getattr(self, "backend", None)
1048
+ if backend is not None:
1049
+ await backend.close()
1050
+ hub = getattr(self, "mcp", None)
1051
+ if hub is not None:
1052
+ await hub.close()
1053
+ jobs = getattr(self, "jobs", None)
1054
+ if jobs is not None:
1055
+ await jobs.close()
1056
+ remote = getattr(self, "remote_tools", None)
1057
+ if remote is not None:
1058
+ await remote.close()
1059
+ net = getattr(self, "agentnet", None)
1060
+ if net is not None:
1061
+ net.leave()
1062
+ self._unsubscribe()
1063
+ tree = getattr(self, "agent_tree", None)
1064
+ if tree is not None:
1065
+ # Every kernel in the tree, sub-agents' included, ends with the session.
1066
+ self.agent_tree = None
1067
+ await tree.close()
1068
+ if self._tip_id and self._pending_header is None:
1069
+ await self._append_record(
1070
+ TipRecord(entry_id=self._tip_id), advance_tip=False
1071
+ )
1072
+
1073
+ # ── mid-run queues ─────────────────────────────────────────────────
1074
+
1075
+ def inject(self, content: str) -> QueueShiftEvent:
1076
+ """Queue a steering message for the active or next run."""
1077
+ return self._harness.inject(content)
1078
+
1079
+ def enqueue(self, content: str) -> QueueShiftEvent:
1080
+ """Queue a follow-up message for when the active run stops."""
1081
+ return self._harness.enqueue(content)
1082
+
1083
+ @property
1084
+ def pending(self) -> PendingBatch:
1085
+ """Snapshot of queued injection and follow-up messages."""
1086
+ return self._harness.pending
1087
+
1088
+ def flush_queues(self) -> PendingBatch:
1089
+ """Clear and return all queued messages."""
1090
+ return self._harness.flush_queues()
1091
+
1092
+ # ── model / provider switching ─────────────────────────────────────
1093
+
1094
+ async def switch_model(self, model: str) -> None:
1095
+ """Switch to a different model identifier (takes effect next turn)."""
1096
+ refinement = getattr(self, "refinement", None)
1097
+ if refinement is not None:
1098
+ refinement.cancel()
1099
+ self._model = model
1100
+ tree = getattr(self, "agent_tree", None)
1101
+ if tree is not None:
1102
+ tree.model = model
1103
+ if self._route.mode == "automatic":
1104
+ # A learned backend was learned for the previous model.
1105
+ self._route = RoutePin()
1106
+ self._sync_route_to_catalog()
1107
+ self._apply_route()
1108
+ await self._append_record(ModelSwapRecord(model=model))
1109
+ self._apply_model_preferences()
1110
+
1111
+ def _apply_model_preferences(self):
1112
+ from rlmagent.model.preferences import read
1113
+
1114
+ overrides = read(self._provider_name)["overrides"].get(self.model, {})
1115
+ setter = getattr(self._provider, "set_service_tier", None)
1116
+ if setter is not None and getattr(self._provider, "supports_service_tier", True):
1117
+ setter(overrides.get("tier", getattr(self._provider, "default_service_tier", None)))
1118
+
1119
+ async def switch_provider(
1120
+ self,
1121
+ provider: ModelProvider,
1122
+ provider_name: str,
1123
+ model: str | None = None,
1124
+ ) -> None:
1125
+ """Replace the active model provider (takes effect next turn)."""
1126
+ refinement = getattr(self, "refinement", None)
1127
+ if refinement is not None:
1128
+ refinement.cancel()
1129
+ tree = getattr(self, "agent_tree", None)
1130
+ if tree is not None:
1131
+ budgeted = tree.use_provider(provider, provider_name)
1132
+ provider = self._provider if getattr(self._provider, "follows_tree", False) else budgeted
1133
+ self._provider = provider
1134
+ self._provider_name = provider_name
1135
+ self._route = RoutePin()
1136
+ self._apply_route()
1137
+ self._sync_route_to_catalog()
1138
+ if self._catalog is not None:
1139
+ existing = self._catalog.get(self._session_id)
1140
+ if existing is not None:
1141
+ self._catalog.upsert(replace(
1142
+ existing, provider=provider_name, updated_at=time(),
1143
+ ))
1144
+ # A fresh provider carries its own configured reasoning policy; the
1145
+ # session's chosen thinking level has to be re-applied on top.
1146
+ self._apply_thinking_level()
1147
+ self._harness.settings.provider = provider
1148
+ if model:
1149
+ await self.switch_model(model)
1150
+
1151
+ # ── thinking mode ──────────────────────────────────────────────────
1152
+
1153
+ async def set_thinking(self, level: str | None) -> None:
1154
+ """Set the thinking/reasoning depth level (or disable with ``None``)."""
1155
+ self._thinking_level = level
1156
+ self._apply_thinking_level()
1157
+ await self._append_record(ReasoningLevelRecord(thinking_level=level))
1158
+
1159
+ def _apply_thinking_level(self) -> None:
1160
+ """Push the active thinking level onto the provider."""
1161
+ setter = getattr(self._provider, "set_reasoning", None)
1162
+ if setter is None:
1163
+ return
1164
+ from rlmagent.reasoning import (
1165
+ normalize_thinking_level,
1166
+ thinking_to_budget,
1167
+ thinking_to_effort,
1168
+ )
1169
+ from rlmagent.model.settings import ReasoningPolicy
1170
+
1171
+ if self._thinking_level is None:
1172
+ setter(ReasoningPolicy(enabled=False))
1173
+ return
1174
+ level = normalize_thinking_level(self._thinking_level)
1175
+ setter(ReasoningPolicy(
1176
+ enabled=True,
1177
+ budget_tokens=thinking_to_budget(level),
1178
+ effort=thinking_to_effort(level),
1179
+ ))
1180
+
1181
+ @property
1182
+ def thinking_level(self) -> str | None:
1183
+ """The current thinking/reasoning depth, or ``None`` if disabled."""
1184
+ return self._thinking_level
1185
+
1186
+ # ── plan mode ──────────────────────────────────────────────────────
1187
+
1188
+ @property
1189
+ def plan_mode(self) -> bool:
1190
+ """Whether the session is restricted to research-and-propose."""
1191
+ return self._plan_mode
1192
+
1193
+ def set_plan_mode(self, enabled: bool) -> None:
1194
+ """Enter or leave plan mode (takes effect on the next turn)."""
1195
+ self._plan_mode = enabled
1196
+ self._apply_plan_mode()
1197
+
1198
+ def _apply_plan_mode(self) -> None:
1199
+ """Push the tool set and system prompt matching the current mode."""
1200
+ from rlmagent.planning import plan_system_prompt, restrict_tools
1201
+
1202
+ if self._plan_mode:
1203
+ self._harness.settings.tools = restrict_tools(self._all_tools)
1204
+ self._harness.settings.system = plan_system_prompt(self._system)
1205
+ else:
1206
+ self._harness.settings.tools = list(self._all_tools)
1207
+ self._harness.settings.system = self._system
1208
+
1209
+ # ── context compaction ─────────────────────────────────────────────
1210
+
1211
+ async def compact(self) -> PruneSummaryEntry:
1212
+ limit = getattr(getattr(self, "policy", None), "max_compactions", None)
1213
+ done = getattr(self, "compactions", 0)
1214
+ if limit is not None and done >= limit:
1215
+ raise RuntimeError(f"this run allows {limit} compaction(s) and has used them")
1216
+ """Compact the transcript by summarising older entries."""
1217
+ transcript = list(self._harness.transcript)
1218
+ plan = plan_compaction(transcript, self._record_ids, limits=_CONTEXT_LIMITS)
1219
+ if plan is None:
1220
+ raise ValueError(
1221
+ f"Transcript has {len(transcript)} entries; "
1222
+ f"need at least {_CONTEXT_LIMITS.min_entries} to compact."
1223
+ )
1224
+
1225
+ # A transcript that was compacted before carries its earlier summary
1226
+ # into this one, so repeated compactions merge rather than stack.
1227
+ system_prompt, user_prompt = build_summary_prompts(
1228
+ plan,
1229
+ system=_CONTEXT_LIMITS.summary_system,
1230
+ max_chars=_CONTEXT_LIMITS.max_summary_chars,
1231
+ )
1232
+ summary_text = ""
1233
+ cap = _CONTEXT_LIMITS.max_summary_chars
1234
+ prompt = user_prompt
1235
+ for attempt in range(3):
1236
+ try:
1237
+ candidate = await self._utility_completion(prompt, system_prompt)
1238
+ if not candidate.strip():
1239
+ raise ValueError("the summary response was empty")
1240
+ if len(candidate) > cap and attempt < 2:
1241
+ # Slicing would drop whatever came last, often the most
1242
+ # recent state. Ask again for a tighter summary instead.
1243
+ prompt = (
1244
+ f"{user_prompt}\n\nYour last summary ran to {len(candidate)} "
1245
+ f"characters; the limit is {cap}. Write it again, shorter, "
1246
+ "keeping the newest facts and open work."
1247
+ )
1248
+ continue
1249
+ except ValueError as error:
1250
+ if attempt == 2:
1251
+ raise ValueError("Compaction failed after 3 invalid summaries; history was retained") from error
1252
+ else:
1253
+ summary_text = candidate[:cap]
1254
+ break
1255
+ # The transcript shrinks but the kernel does not: say so, or the
1256
+ # model rebuilds variables it still has.
1257
+ if not summary_text.rstrip().endswith(COMPACTION_NOTE):
1258
+ summary_text = f"{summary_text.rstrip()}\n\n{COMPACTION_NOTE}"
1259
+
1260
+ prune_record = PruneRecord(
1261
+ summary=summary_text,
1262
+ replaces_entry_ids=list(plan.summarize_ids),
1263
+ )
1264
+ await self._append_record(prune_record)
1265
+
1266
+ result = apply_compaction(
1267
+ plan, summary_text, summary_record_id=prune_record.id
1268
+ )
1269
+ self._harness.set_messages(list(result.transcript))
1270
+ self.compactions = done + 1
1271
+ hook = getattr(self, "on_compacted", None)
1272
+ if hook is not None:
1273
+ hook()
1274
+ self._record_ids = list(result.record_ids)
1275
+ refinement = getattr(self, "refinement", None)
1276
+ if refinement is not None:
1277
+ refinement.compacted(transcript)
1278
+
1279
+ return result.summary_entry
1280
+
1281
+ def should_compact(self) -> bool:
1282
+ """Return whether automatic compaction is recommended."""
1283
+ return exceeds_threshold(self.context_estimate())
1284
+
1285
+ def context_estimate(self) -> ContextEstimate:
1286
+ """Full context accounting for the session as it stands."""
1287
+ return estimate_context(
1288
+ self._harness.transcript,
1289
+ system=self._system,
1290
+ tools=self._harness.settings.tools,
1291
+ limits=_CONTEXT_LIMITS,
1292
+ model=self._model,
1293
+ )
1294
+
1295
+ # ── catalog sync ───────────────────────────────────────────────────
1296
+
1297
+ @property
1298
+ def catalog(self) -> SessionCatalog | None:
1299
+ """The session catalog, or ``None`` for ephemeral sessions."""
1300
+ return self._catalog
1301
+
1302
+ def _sync_title_to_catalog(self, title: str) -> None:
1303
+ """Push a title change to the session catalog, if one is active."""
1304
+ if self._catalog is None:
1305
+ return
1306
+ existing = self._catalog.get(self._session_id)
1307
+ if existing is None:
1308
+ return
1309
+ self._catalog.upsert(replace(existing, title=title, updated_at=time()))
1310
+
1311
+ # ── inference routing ──────────────────────────────────────────────
1312
+
1313
+ @property
1314
+ def inference_route(self) -> RoutePin:
1315
+ """The backend pin in effect for a routed provider."""
1316
+ return self._route
1317
+
1318
+ def set_inference_route(self, backend: str | None) -> None:
1319
+ """Choose a backend (fixed) or hand the choice back to the router."""
1320
+ self._route = RoutePin("fixed", backend) if backend else RoutePin()
1321
+ self._apply_route()
1322
+ self._sync_route_to_catalog()
1323
+
1324
+ def _apply_route(self) -> None:
1325
+ """Send the routed model id when this provider routes, else the plain one."""
1326
+ routed = self._provider_name == ROUTED_PROVIDER
1327
+ self._harness.settings.model = (
1328
+ self._route.model_for(self._model) if routed else self._model
1329
+ )
1330
+
1331
+ def _sync_route_to_catalog(self) -> None:
1332
+ if self._catalog is None:
1333
+ return
1334
+ existing = self._catalog.get(self._session_id)
1335
+ if existing is None:
1336
+ return
1337
+ self._catalog.upsert(replace(
1338
+ existing,
1339
+ inference_provider=self._route.backend,
1340
+ inference_provider_mode=self._route.mode,
1341
+ updated_at=time(),
1342
+ ))
1343
+
1344
+ def _learn_route(self) -> None:
1345
+ """Pin the backend that just answered, for an automatic route."""
1346
+ if self._provider_name != ROUTED_PROVIDER or self._route.backend:
1347
+ return
1348
+ if self._route.mode != "automatic":
1349
+ return
1350
+ served = getattr(self._provider, "served_by", None)
1351
+ if served:
1352
+ self._route = RoutePin("automatic", served)
1353
+ self._apply_route()
1354
+ self._sync_route_to_catalog()
1355
+
1356
+ def _should_reroute(self, reply: ModelEntry) -> bool:
1357
+ """Whether a failed reply should retry on the router's own choice."""
1358
+ return (
1359
+ self._provider_name == ROUTED_PROVIDER
1360
+ and self._route.mode == "automatic"
1361
+ and self._route.backend is not None
1362
+ and reply.stop_reason == "error"
1363
+ and not reply.content
1364
+ and is_route_failure(getattr(self._provider, "last_failure_status", None))
1365
+ )
1366
+
1367
+ # ── slash-command dispatch ─────────────────────────────────────────
1368
+
1369
+ #: Registry commands this session offers. See ``_RUNNABLE_COMMANDS``.
1370
+ RUNNABLE_COMMANDS: frozenset[str] = _RUNNABLE_COMMANDS
1371
+
1372
+ #: Slash commands, with one-line help. Drives autocomplete and /help.
1373
+ #: Derived from the shared registry so the CLI, the TUI, and dispatch all
1374
+ #: read one list — a command cannot be completable but unrunnable, or
1375
+ #: runnable but undiscoverable.
1376
+ COMMANDS: tuple[tuple[str, str], ...] = _RUNNABLE_COMMAND_TABLE
1377
+
1378
+ async def handle_command(self, text: str) -> str | None:
1379
+ """Handle a ``/``-prefixed command."""
1380
+ parsed = parse_command(text)
1381
+ if parsed is None:
1382
+ return None
1383
+
1384
+ name, arg = parsed
1385
+ # Resolve through the registry so aliases (/m, /fork, /title, /sh …)
1386
+ # reach the same handler as their primary name.
1387
+ command = COMMAND_REGISTRY.get(name)
1388
+ if command is None:
1389
+ return None
1390
+
1391
+ handler = self._command_handlers().get(command.name)
1392
+ if handler is None:
1393
+ # A registered command the session itself does not run (/quit,
1394
+ # /help, pickers). The frontend owns those; returning None lets it
1395
+ # decide rather than sending the text to the model as a prompt.
1396
+ return None
1397
+
1398
+ result = handler(arg)
1399
+ if asyncio.iscoroutine(result):
1400
+ return await result # type: ignore[misc]
1401
+ return result # type: ignore[return-value]
1402
+
1403
+ def _command_handlers(self) -> dict[str, Callable[[str], object]]:
1404
+ """Map registry command names to this session's bound handlers."""
1405
+ return {
1406
+ "model": self._cmd_model,
1407
+ "provider": self._cmd_provider,
1408
+ "think": self._cmd_think,
1409
+ "tier": self._cmd_tier,
1410
+ "fast": self._cmd_fast,
1411
+ "plan": self._cmd_plan,
1412
+ "compact": self._cmd_compact,
1413
+ "improve": self._cmd_improve,
1414
+ "refine": self._cmd_refine,
1415
+ "tools": self._cmd_tools,
1416
+ "undo": self._cmd_undo,
1417
+ "diff": self._cmd_diff,
1418
+ "tasks": self._cmd_tasks,
1419
+ "mcp": self._cmd_mcp,
1420
+ "goal": self._cmd_goal,
1421
+ "btw": self._cmd_btw,
1422
+ "update": self._cmd_update,
1423
+ "share": self._cmd_share,
1424
+ "logs": self._cmd_logs,
1425
+ "changelog": self._cmd_changelog,
1426
+ "image": self._cmd_image,
1427
+ "autonomous": self._cmd_autonomous,
1428
+ "stats": self._cmd_stats,
1429
+ "name": self._cmd_name,
1430
+ "export": self._cmd_export,
1431
+ "branch": self._cmd_branch,
1432
+ "branches": self._cmd_branches,
1433
+ "rewind": self._cmd_rewind,
1434
+ "shell": self._cmd_shell,
1435
+ "skills": self._cmd_skills,
1436
+ "create-skill": self._cmd_create_skill,
1437
+ "reload": self._cmd_reload,
1438
+ "diag": self._cmd_diag,
1439
+ "system": self._cmd_system,
1440
+ "session": self._cmd_session,
1441
+ "help": self._cmd_help,
1442
+ }
1443
+
1444
+ def _cmd_help(self, _arg: str) -> str:
1445
+ """List every command available here, with usage and aliases."""
1446
+ lines = ["Commands:"]
1447
+ for command in COMMAND_REGISTRY.all_commands():
1448
+ if command.name not in _RUNNABLE_COMMANDS:
1449
+ continue
1450
+ aliases = (
1451
+ " (" + " ".join(f"/{a}" for a in command.aliases) + ")"
1452
+ if command.aliases
1453
+ else ""
1454
+ )
1455
+ lines.append(f" {command.display_usage:<28}{command.description}{aliases}")
1456
+ return "\n".join(lines)
1457
+
1458
+ # ── terminal command execution ─────────────────────────────────────
1459
+
1460
+ async def run_shell(
1461
+ self,
1462
+ command: str,
1463
+ *,
1464
+ inject: bool = False,
1465
+ timeout: float = 30.0,
1466
+ ) -> ShellResultEntry:
1467
+ """Execute a shell command and optionally inject output into context."""
1468
+ try:
1469
+ result = await asyncio.wait_for(
1470
+ asyncio.to_thread(
1471
+ subprocess.run,
1472
+ command,
1473
+ shell=True, # noqa: S602 — intentional user-controlled execution
1474
+ stdin=subprocess.DEVNULL,
1475
+ capture_output=True,
1476
+ text=True,
1477
+ encoding="utf-8",
1478
+ errors="replace",
1479
+ timeout=timeout,
1480
+ ),
1481
+ timeout=timeout + 5,
1482
+ )
1483
+ output = result.stdout
1484
+ if result.stderr:
1485
+ output += ("\n" if output else "") + result.stderr
1486
+ entry = ShellResultEntry(
1487
+ command=command,
1488
+ output=output,
1489
+ exit_code=result.returncode,
1490
+ )
1491
+ except (TimeoutError, subprocess.TimeoutExpired):
1492
+ entry = ShellResultEntry(
1493
+ command=command,
1494
+ output="Command timed out",
1495
+ exit_code=None,
1496
+ cancelled=True,
1497
+ )
1498
+
1499
+ if inject:
1500
+ self._harness.push_message(entry)
1501
+ await self._persist_entry(entry)
1502
+ return entry
1503
+
1504
+ # ── session tree: branching and rewinding ───────────────────────────
1505
+
1506
+ async def tree_choices(self) -> tuple[SessionTreeChoice, ...]:
1507
+ """Return branchable session entries for a tree picker."""
1508
+ if self._vault is None:
1509
+ return ()
1510
+ records = await self._vault.read_all()
1511
+ indents = _tree_branch_indents(records)
1512
+ return tuple(
1513
+ SessionTreeChoice(
1514
+ entry_id=record.id,
1515
+ label=_tree_choice_label(
1516
+ record, branch_indent=indents.get(record.id, 0)
1517
+ ),
1518
+ active=record.id == self._tip_id,
1519
+ is_tool_call=_is_tool_call_record(record),
1520
+ )
1521
+ for record in _ordered_tree_records(records)
1522
+ if _is_branchable_record(record)
1523
+ )
1524
+
1525
+ async def branch(self, summary: str | None = None) -> str:
1526
+ """Create a conversation branch from the current point."""
1527
+ refinement = getattr(self, "refinement", None)
1528
+ if refinement is not None:
1529
+ refinement.cancel()
1530
+ if summary is None:
1531
+ context = "\n".join(
1532
+ f"[{e.role}] {surface_text(e)}"
1533
+ for e in self._harness.transcript[-4:]
1534
+ )
1535
+ summary = await self._utility_completion(
1536
+ f"Summarize this conversation branch concisely:\n{context}",
1537
+ "Produce a 1-2 sentence summary of the conversation so far.",
1538
+ )
1539
+
1540
+ record = ForkSummaryRecord(
1541
+ summary=summary.strip(),
1542
+ branch_root_id=self._tip_id,
1543
+ )
1544
+ await self._append_record(record)
1545
+ return record.id
1546
+
1547
+ async def rewind(self, entry_id: str) -> None:
1548
+ """Rewind the session to a previous point in the conversation tree."""
1549
+ refinement = getattr(self, "refinement", None)
1550
+ if refinement is not None:
1551
+ refinement.cancel()
1552
+ if self._vault is None:
1553
+ raise RuntimeError("Rewind requires a persistent session.")
1554
+
1555
+ records = await self._vault.read_all()
1556
+ path = trace_to_entry(records, entry_id)
1557
+ state = project_records(path)
1558
+
1559
+ self._harness.set_messages(state.transcript)
1560
+ self._record_ids = state.record_ids
1561
+ self._tip_id = entry_id
1562
+ # The active model stays: an older point may predate a model or
1563
+ # provider switch, and its model would not fit the current provider.
1564
+ if state.thinking_level is not None:
1565
+ self._thinking_level = state.thinking_level
1566
+ if state.title:
1567
+ self._title = state.title
1568
+
1569
+ await self._append_record(
1570
+ TipRecord(entry_id=entry_id), advance_tip=False
1571
+ )
1572
+ await self._mend_stored_history()
1573
+
1574
+ if refinement is not None:
1575
+ refinement.at_boundary()
1576
+
1577
+ async def _mend_stored_history(self) -> HistoryMend | None:
1578
+ """Repair malformed tool-call pairing in the active branch, durably."""
1579
+ transcript = list(self._harness.transcript)
1580
+ mend = mend_tool_history(transcript)
1581
+ if not mend.changed:
1582
+ return None
1583
+ self._harness.set_messages(list(mend.entries))
1584
+ if self._vault is None:
1585
+ return mend
1586
+
1587
+ records = await self._vault.read_all()
1588
+ parent_id, fresh = self._repair_branch_point(records, transcript, mend.entries)
1589
+ dropped = _records_after(records, self._tip_id, parent_id)
1590
+
1591
+ staged: list[SessionRecord] = [
1592
+ ExtensionRecord(
1593
+ parent_id=parent_id,
1594
+ namespace=_HISTORY_REPAIR_NAMESPACE,
1595
+ data={"version": 1, **mend.counters()},
1596
+ )
1597
+ ]
1598
+ staged += [TranscriptRecord(message=entry) for entry in fresh]
1599
+ staged.append(ModelSwapRecord(model=self._model))
1600
+ staged.append(ReasoningLevelRecord(thinking_level=self._thinking_level))
1601
+ if self._title:
1602
+ staged.append(TagRecord(label=self._title))
1603
+ # Application records that sat on the abandoned stretch travel along.
1604
+ staged += [
1605
+ ExtensionRecord(namespace=r.namespace, data=r.data)
1606
+ for r in dropped
1607
+ if isinstance(r, ExtensionRecord) and r.namespace != _HISTORY_REPAIR_NAMESPACE
1608
+ ]
1609
+ for previous, record in zip(staged, staged[1:], strict=False):
1610
+ record.parent_id = previous.id
1611
+ for record in staged:
1612
+ await self._vault.append(record)
1613
+ self._tip_id = staged[-1].id
1614
+ await self._append_record(TipRecord(entry_id=self._tip_id), advance_tip=False)
1615
+
1616
+ state = project_records(trace_to_entry([*records, *staged], self._tip_id))
1617
+ self._record_ids = state.record_ids
1618
+ return mend
1619
+
1620
+ @staticmethod
1621
+ def _repair_branch_point(
1622
+ records: list[SessionRecord],
1623
+ original: list[TranscriptEntry],
1624
+ repaired: tuple[TranscriptEntry, ...],
1625
+ ) -> tuple[str | None, list[TranscriptEntry]]:
1626
+ """Where the repair branch attaches, and which messages it must carry."""
1627
+ prefix = 0
1628
+ for before, after in zip(original, repaired, strict=False):
1629
+ if before is not after:
1630
+ break
1631
+ prefix += 1
1632
+ by_id = {record.id: record for record in records}
1633
+ state = project_active_branch(records)
1634
+ if prefix and len(state.record_ids) >= prefix:
1635
+ candidate = state.record_ids[prefix - 1]
1636
+ if candidate in by_id:
1637
+ try:
1638
+ projected = project_records(trace_to_entry(records, candidate)).transcript
1639
+ except TreeIntegrityError:
1640
+ projected = []
1641
+ if projected == original[:prefix]:
1642
+ return candidate, list(repaired[prefix:])
1643
+ header = next((r for r in records if isinstance(r, SessionMetaRecord)), None)
1644
+ return (header.id if header else None), list(repaired)
1645
+
1646
+ # ── multi-session lifecycle ────────────────────────────────────────
1647
+
1648
+ async def new_session(
1649
+ self,
1650
+ sessions_dir: Path | None = None,
1651
+ ) -> CodingSession:
1652
+ """Create a fresh session, preserving the current provider/model/tools."""
1653
+ leased = getattr(self, "_writer_lease", None) is not None
1654
+ await self.shutdown()
1655
+ base = sessions_dir
1656
+ if base is None and self._vault:
1657
+ base = self._vault.path.parent
1658
+ replacement = await CodingSession.create(
1659
+ provider=self._provider,
1660
+ provider_name=self._provider_name,
1661
+ model=self._model,
1662
+ system=self._system,
1663
+ tools=list(self._all_tools),
1664
+ sessions_dir=base,
1665
+ tools_loader=self._tools_loader,
1666
+ skills_loader=self._skills_loader,
1667
+ templates_loader=self._templates_loader,
1668
+ )
1669
+ if leased and base is not None:
1670
+ from rlmagent.harness.session.lease import acquire
1671
+
1672
+ replacement._writer_lease = acquire(base, replacement.session_id)
1673
+ refinement = getattr(self, 'refinement', None)
1674
+ if refinement is not None:
1675
+ refinement.transfer(replacement)
1676
+ return replacement
1677
+
1678
+ async def replace_session(self, session_id: str) -> CodingSession:
1679
+ """Switch to a different existing session."""
1680
+ if self._vault is None:
1681
+ raise RuntimeError("Replace requires a persistent session.")
1682
+ if session_id == self.session_id:
1683
+ return self
1684
+ lease = None
1685
+ base = self._vault.path.parent
1686
+ if getattr(self, "_writer_lease", None) is not None:
1687
+ from rlmagent.harness.session.lease import acquire
1688
+
1689
+ lease = acquire(base, session_id)
1690
+ try:
1691
+ await self.shutdown()
1692
+ replacement = await CodingSession.resume(
1693
+ session_id,
1694
+ provider=self._provider,
1695
+ provider_name=self._provider_name,
1696
+ model=self._model,
1697
+ system=self._system,
1698
+ tools=list(self._all_tools),
1699
+ sessions_dir=base,
1700
+ tools_loader=self._tools_loader,
1701
+ skills_loader=self._skills_loader,
1702
+ templates_loader=self._templates_loader,
1703
+ )
1704
+ except BaseException:
1705
+ if lease is not None:
1706
+ lease.close()
1707
+ raise
1708
+ replacement._writer_lease = lease
1709
+ refinement = getattr(self, 'refinement', None)
1710
+ if refinement is not None:
1711
+ refinement.transfer(replacement)
1712
+ return replacement
1713
+
1714
+ # ── extensions and hot reload ──────────────────────────────────────
1715
+
1716
+ def load_extensions(self) -> list[object]:
1717
+ """Discover and activate extensions from entry points."""
1718
+ loaded: list[object] = []
1719
+ try:
1720
+ eps = importlib.metadata.entry_points(group=_EXTENSION_GROUP)
1721
+ except TypeError:
1722
+ eps = importlib.metadata.entry_points().get( # type: ignore[assignment]
1723
+ _EXTENSION_GROUP, []
1724
+ )
1725
+ for ep in eps:
1726
+ try:
1727
+ ext = ep.load()()
1728
+ if ext is not None:
1729
+ loaded.append(ext)
1730
+ except Exception: # noqa: BLE001
1731
+ pass
1732
+ self._extensions = loaded
1733
+ return loaded
1734
+
1735
+ async def reload(self) -> None:
1736
+ """Hot-reload tools, skills, extensions, and system prompt."""
1737
+ self._extensions = self.load_extensions()
1738
+ if self._tools_loader:
1739
+ self._all_tools = self._tools_loader()
1740
+ self.reload_skills()
1741
+ self._apply_plan_mode()
1742
+
1743
+ # ── skills ─────────────────────────────────────────────────────────
1744
+
1745
+ @property
1746
+ def skills(self) -> tuple[Any, ...]:
1747
+ """Skills currently available to this session."""
1748
+ return tuple(self._skills)
1749
+
1750
+ def get_skill(self, name: str) -> Any | None:
1751
+ """Look up a loaded skill by name, case-insensitively."""
1752
+ wanted = name.strip().lstrip("/").removeprefix("skill:").lower()
1753
+ for skill in self._skills:
1754
+ if skill.name.lower() == wanted:
1755
+ return skill
1756
+ return None
1757
+
1758
+ def reload_skills(self) -> int:
1759
+ """Re-read skills from disk, returning how many are now loaded."""
1760
+ if self._skills_loader is not None:
1761
+ self._skills = list(self._skills_loader())
1762
+ refinement = getattr(self, "refinement", None)
1763
+ if refinement is not None:
1764
+ refinement.installed = list(self._skills)
1765
+ refinement._apply_active()
1766
+ return len(self._skills)
1767
+
1768
+ def expand_skill(self, text: str) -> str | None:
1769
+ """Expand ``/skill:<name> [args]`` into an injectable prompt block."""
1770
+ from rlmagent.skillset import expand_command
1771
+
1772
+ index = {s.name: s for s in self._skills}
1773
+ try:
1774
+ return expand_command(text, index)
1775
+ except KeyError:
1776
+ return None
1777
+
1778
+ # ── prompt templates ───────────────────────────────────────────────
1779
+
1780
+ @property
1781
+ def prompt_templates(self) -> tuple[Any, ...]:
1782
+ """Markdown prompt templates available to this session."""
1783
+ return tuple(self._templates)
1784
+
1785
+ def reload_prompt_templates(self) -> int:
1786
+ """Re-read prompt templates from disk, returning how many are loaded."""
1787
+ if self._templates_loader is not None:
1788
+ self._templates = list(self._templates_loader())
1789
+ return len(self._templates)
1790
+
1791
+ def expand_template(self, text: str) -> str | None:
1792
+ """Expand ``/<template-name> [args]`` into its rendered body."""
1793
+ from rlmagent.prompts import expand_slash_command
1794
+
1795
+ index = {t.name: t for t in self._templates}
1796
+ if not index:
1797
+ return None
1798
+ try:
1799
+ return expand_slash_command(text, index)
1800
+ except KeyError:
1801
+ return None
1802
+
1803
+ # ── exports ────────────────────────────────────────────────────────
1804
+
1805
+ async def export(self, fmt: str = "text") -> str:
1806
+ """Export the session transcript in the given format (text/json/jsonl)."""
1807
+ if fmt == "html":
1808
+ from rlmagent.session_html import render
1809
+
1810
+ messages = list(self.transcript)
1811
+ records = None
1812
+ if self._vault is not None:
1813
+ records = await self._vault.read_all()
1814
+ return render(messages, self.title or self.session_id, records=records)
1815
+ if self._vault is not None:
1816
+ return await self._export_from_vault(fmt)
1817
+ return self._export_from_transcript(fmt)
1818
+
1819
+ async def _export_from_vault(self, fmt: str) -> str:
1820
+ assert self._vault is not None
1821
+ records = await self._vault.read_all()
1822
+ if fmt == "jsonl":
1823
+ from rlmagent.harness.session.store import serialize_record
1824
+
1825
+ return "".join(serialize_record(r) for r in records)
1826
+ if fmt == "json":
1827
+ from pydantic import TypeAdapter
1828
+
1829
+ adapter: TypeAdapter[list[SessionRecord]] = TypeAdapter(list[SessionRecord])
1830
+ return adapter.dump_json(records, indent=2).decode()
1831
+ lines: list[str] = []
1832
+ for r in records:
1833
+ if isinstance(r, TranscriptRecord):
1834
+ lines.append(f"[{r.message.role}] {surface_text(r.message)}")
1835
+ return "\n\n".join(lines)
1836
+
1837
+ def _export_from_transcript(self, fmt: str) -> str:
1838
+ transcript = self._harness.transcript
1839
+ if fmt == "json":
1840
+ from pydantic import TypeAdapter
1841
+
1842
+ adapter: TypeAdapter[list[TranscriptEntry]] = TypeAdapter(list[TranscriptEntry])
1843
+ return adapter.dump_json(list(transcript), indent=2).decode()
1844
+ return "\n\n".join(
1845
+ f"[{e.role}] {surface_text(e)}" for e in transcript
1846
+ )
1847
+
1848
+ # ── diagnostics ────────────────────────────────────────────────────
1849
+
1850
+ def diagnostics(self) -> dict[str, JValue]:
1851
+ """Return a snapshot of session state for debugging."""
1852
+ return {
1853
+ "session_id": self._session_id,
1854
+ "model": self._model,
1855
+ "provider": self._provider_name,
1856
+ "thinking_level": self._thinking_level,
1857
+ "title": self._title,
1858
+ "message_count": len(self._harness.transcript),
1859
+ "turn_count": self._turn_count,
1860
+ "total_input_tokens": self._total_input,
1861
+ "total_output_tokens": self._total_output,
1862
+ "total_cost_usd": self._total_cost,
1863
+ "estimated_context_tokens": self._estimate_context_tokens(),
1864
+ "context_limit": self._context_limit(),
1865
+ "active": self._harness.active,
1866
+ "pending_count": self._harness.pending_count,
1867
+ "persistent": self._vault is not None,
1868
+ "cwd": self._cwd,
1869
+ "extensions_loaded": len(self._extensions),
1870
+ }
1871
+
1872
+ # ── read-only API ──────────────────────────────────────────────────
1873
+
1874
+ @property
1875
+ def session_id(self) -> str:
1876
+ """Unique identifier for this session."""
1877
+ return self._session_id
1878
+
1879
+ @property
1880
+ def transcript(self) -> tuple[TranscriptEntry, ...]:
1881
+ """Immutable snapshot of the current conversation."""
1882
+ return self._harness.transcript
1883
+
1884
+ @property
1885
+ def model(self) -> str:
1886
+ """The active model identifier."""
1887
+ return self._model
1888
+
1889
+ @property
1890
+ def provider_name(self) -> str:
1891
+ """The active provider name."""
1892
+ return self._provider_name
1893
+
1894
+ @property
1895
+ def tools(self) -> tuple[ToolSpec, ...]:
1896
+ """The currently registered tool specifications."""
1897
+ return tuple(self._harness.settings.tools)
1898
+
1899
+ @property
1900
+ def active(self) -> bool:
1901
+ """Whether an agent run is in progress."""
1902
+ return self._harness.active
1903
+
1904
+ @property
1905
+ def title(self) -> str | None:
1906
+ """The session display name, if set."""
1907
+ return self._title
1908
+
1909
+ @property
1910
+ def cwd(self) -> str:
1911
+ """Working directory associated with this session."""
1912
+ return self._cwd
1913
+
1914
+ @property
1915
+ def usage(self) -> SessionStats:
1916
+ """Cumulative usage statistics."""
1917
+ return SessionStats(
1918
+ turn_count=self._turn_count,
1919
+ message_count=len(self._harness.transcript),
1920
+ total_input_tokens=self._total_input,
1921
+ total_output_tokens=self._total_output,
1922
+ total_cost_usd=self._total_cost,
1923
+ estimated_context_tokens=self._estimate_context_tokens(),
1924
+ context_limit=self._context_limit(),
1925
+ cache_read_tokens=self._cache_read,
1926
+ cache_write_tokens=self._cache_write,
1927
+ latest_prompt_tokens=self._latest_prompt,
1928
+ latest_cache_read_tokens=self._latest_cache_read,
1929
+ )
1930
+
1931
+ @property
1932
+ def harness(self) -> RuntimeHarness:
1933
+ """Direct access to the underlying harness (advanced use)."""
1934
+ return self._harness
1935
+
1936
+ # ── internal: event handling ────────────────────────────────────────
1937
+
1938
+ async def _on_harness_event(self, event: AgentEvent) -> None:
1939
+ """Subscriber called by the harness for every streamed event."""
1940
+ if not isinstance(event, MessageEndEvent):
1941
+ return
1942
+ record_id = await self._persist_entry(event.message)
1943
+ if record_id:
1944
+ self._record_ids.append(record_id)
1945
+ if isinstance(event.message, ModelEntry):
1946
+ self._tally(event.message)
1947
+
1948
+ def _tally(self, reply: ModelEntry) -> None:
1949
+ """Fold one model reply's usage into the session totals."""
1950
+ usage = reply.usage
1951
+ prompt = usage.input + usage.cache_read + usage.cache_write
1952
+ self._turn_count += 1
1953
+ self._total_input += prompt
1954
+ self._total_output += usage.output
1955
+ self._total_cost += usage.cost.total
1956
+ self._cache_read += usage.cache_read
1957
+ self._cache_write += usage.cache_write
1958
+ self._latest_prompt = prompt
1959
+ self._latest_cache_read = usage.cache_read
1960
+
1961
+ async def _run_wrapped(
1962
+ self, stream: AsyncIterator[AgentEvent]
1963
+ ) -> AsyncIterator[AgentEvent]:
1964
+ """Wrap a harness event stream with post-run processing."""
1965
+ completed = False
1966
+ used: set[str] = set()
1967
+ backend_events = getattr(getattr(self, "backend", None), "events", ())
1968
+ first_event = len(backend_events)
1969
+ try:
1970
+ while True:
1971
+ recovery: str | None = None
1972
+ # Events for the failed reply a recovery replaces. A reply's
1973
+ # start is held until its outcome is known, so a recovered
1974
+ # failure never reaches the caller as an error.
1975
+ withheld: list[AgentEvent] = []
1976
+ async for event in stream:
1977
+ if recovery is not None:
1978
+ # The rest of a run being recovered is not shown.
1979
+ withheld.append(event)
1980
+ continue
1981
+ if isinstance(event, MessageStartEvent) and isinstance(
1982
+ event.message, ModelEntry
1983
+ ):
1984
+ withheld = [event]
1985
+ continue
1986
+ if isinstance(event, MessageEndEvent) and isinstance(
1987
+ event.message, ModelEntry
1988
+ ):
1989
+ reply = event.message
1990
+ if "overflow" not in used and _is_context_overflow(reply):
1991
+ recovery = "overflow"
1992
+ elif "reroute" not in used and self._should_reroute(reply):
1993
+ recovery = "reroute"
1994
+ elif reply.stop_reason not in ("error", "aborted"):
1995
+ self._learn_route()
1996
+ if recovery is not None:
1997
+ withheld.append(event)
1998
+ continue
1999
+ for early in withheld:
2000
+ yield early
2001
+ withheld = []
2002
+ if isinstance(event, RunEndEvent):
2003
+ completed = True
2004
+ pilot = getattr(self, "autopilot", None)
2005
+ if pilot is not None and pilot.autonomous:
2006
+ pilot.observe_turn(event.messages, backend_events[first_event:])
2007
+ yield event
2008
+ if recovery is None:
2009
+ for early in withheld:
2010
+ yield early
2011
+ break
2012
+ used.add(recovery)
2013
+ if recovery == "overflow":
2014
+ if not await self._compact_for_overflow():
2015
+ # Recovery failed: the original error is the answer.
2016
+ for early in withheld:
2017
+ yield early
2018
+ completed = True
2019
+ break
2020
+ notice = "Context window exceeded; compacted older history and retrying."
2021
+ else:
2022
+ failed = self._route.backend
2023
+ self._route = RoutePin()
2024
+ self._apply_route()
2025
+ self._sync_route_to_catalog()
2026
+ notice = f"Inference provider {failed} failed; retrying on the router's choice."
2027
+ yield RetryEvent(attempt=2, max_attempts=2, message=notice)
2028
+ stream = self._harness.resume()
2029
+ finally:
2030
+ if completed:
2031
+ await self._post_run()
2032
+
2033
+ async def _compact_for_overflow(self) -> bool:
2034
+ """Compact after a context-overflow error; ``False`` if that is impossible."""
2035
+ try:
2036
+ await self.compact()
2037
+ except Exception: # noqa: BLE001 - the overflow error itself stays visible
2038
+ return False
2039
+ return True
2040
+
2041
+ async def _post_run(self) -> None:
2042
+ """Housekeeping after a completed agent run."""
2043
+ evidence = getattr(self, "evidence", None)
2044
+ if evidence is not None:
2045
+ evidence.task_finished(self)
2046
+ refinement = getattr(self, "refinement", None)
2047
+ if refinement is not None:
2048
+ refinement.at_boundary()
2049
+ improver = getattr(self, "improver", None)
2050
+ if improver is not None:
2051
+ try:
2052
+ await improver.at_boundary()
2053
+ improver.show_digest()
2054
+ except Exception: # noqa: BLE001
2055
+ pass
2056
+ if self._tip_id:
2057
+ await self._append_record(
2058
+ TipRecord(entry_id=self._tip_id), advance_tip=False
2059
+ )
2060
+
2061
+ asked = getattr(self, "compact_requested", False)
2062
+ self.compact_requested = False
2063
+ if (
2064
+ (asked or self.should_compact())
2065
+ and len(self._harness.transcript) >= _CONTEXT_LIMITS.min_entries
2066
+ ):
2067
+ try:
2068
+ await self.compact()
2069
+ except Exception: # noqa: BLE001
2070
+ pass
2071
+ if refinement is not None:
2072
+ refinement.settled()
2073
+
2074
+ # ── internal: persistence ──────────────────────────────────────────
2075
+
2076
+ async def _append_record(
2077
+ self,
2078
+ record: SessionRecord,
2079
+ *,
2080
+ advance_tip: bool = True,
2081
+ ) -> str | None:
2082
+ """Chain *record* onto the active branch and persist it."""
2083
+ if self._vault is None:
2084
+ return None
2085
+ if self._pending_header is not None:
2086
+ if not await self._write_header():
2087
+ return None
2088
+ if record.parent_id is None:
2089
+ record.parent_id = self._tip_id
2090
+ try:
2091
+ await self._vault.append(record)
2092
+ except Exception as error:
2093
+ raise OSError(f"Could not save session {self._session_id} to {self._vault.path}: {error}") from error
2094
+ if advance_tip:
2095
+ self._tip_id = record.id
2096
+ return record.id
2097
+
2098
+ async def _write_header(self) -> bool:
2099
+ """Write the deferred session header and index row, once."""
2100
+ header = self._pending_header
2101
+ assert header is not None and self._vault is not None
2102
+ try:
2103
+ await self._vault.append(header)
2104
+ except Exception as error:
2105
+ raise OSError(f"Could not create session {self._session_id} at {self._vault.path}: {error}") from error
2106
+ self._pending_header = None
2107
+ if self._catalog is not None:
2108
+ self._catalog.upsert(replace(
2109
+ SessionCatalog.prepare(
2110
+ session_id=self._session_id,
2111
+ vault_path=self._vault.path,
2112
+ cwd=header.cwd or self._cwd,
2113
+ model=self._model,
2114
+ provider=self._provider_name,
2115
+ ),
2116
+ title=self._title,
2117
+ inference_provider=self._route.backend,
2118
+ inference_provider_mode=self._route.mode,
2119
+ ))
2120
+ return True
2121
+
2122
+ async def _persist_entry(self, entry: TranscriptEntry) -> str | None:
2123
+ """Append a transcript record to the vault, returning its record ID."""
2124
+ return await self._append_record(TranscriptRecord(message=entry))
2125
+
2126
+ # ── internal: token estimation ─────────────────────────────────────
2127
+
2128
+ def _estimate_context_tokens(self) -> int:
2129
+ """Estimated tokens currently occupying the context window."""
2130
+ return self.context_estimate().used
2131
+
2132
+ def _context_limit(self) -> int:
2133
+ """Return the context token limit for the current model."""
2134
+ return resolve_window(self._model)
2135
+
2136
+ # ── internal: utility completions ──────────────────────────────────
2137
+
2138
+ async def _utility_completion(self, prompt: str, system: str) -> str:
2139
+ """Get a simple text completion from the provider for internal use."""
2140
+ messages: list[TranscriptEntry] = [HumanEntry(content=prompt)]
2141
+ async for event in self._provider.stream_response(
2142
+ model=self._model,
2143
+ system=system,
2144
+ messages=messages,
2145
+ tools=[],
2146
+ ):
2147
+ if isinstance(event, StreamCloseEvent):
2148
+ from rlmagent.harness.contracts.transcript import CallBlock
2149
+ if any(isinstance(part, CallBlock) for part in event.message.content):
2150
+ raise ValueError("Expected a text response; received a tool call")
2151
+ return event.message.text
2152
+ return ""
2153
+
2154
+ # ── internal: command handlers ─────────────────────────────────────
2155
+
2156
+ async def _cmd_model(self, arg: str) -> str:
2157
+ if not arg:
2158
+ return f"Current model: {self._model}"
2159
+ if arg.split()[0] == "cycle":
2160
+ from rlmagent.model.preferences import cycle, read
2161
+
2162
+ words = arg.split(maxsplit=2)
2163
+ operation = words[1] if len(words) > 1 else "next"
2164
+ if operation in ("add", "remove"):
2165
+ try:
2166
+ models = cycle(self._provider_name, operation, words[2] if len(words) > 2 else None)
2167
+ except ValueError as error:
2168
+ return str(error)
2169
+ return "Model cycle: " + (", ".join(models) or "empty")
2170
+ models = read(self._provider_name)["cycle"]
2171
+ if operation == "list":
2172
+ return "Model cycle: " + (", ".join(models) or "empty")
2173
+ if operation != "next":
2174
+ return "Use /model cycle add|remove NAME, list, or next."
2175
+ if not models:
2176
+ return "The provider's model cycle is empty. Use /model cycle add NAME."
2177
+ arg = models[(models.index(self.model) + 1) % len(models)] if self.model in models else models[0]
2178
+ await self.switch_model(arg)
2179
+ return f"Switched to model: {arg}"
2180
+
2181
+ async def _cmd_provider(self, arg: str) -> str:
2182
+ if not arg:
2183
+ return f"Current provider: {self._provider_name}"
2184
+ from rlmagent.config.config import resolve_provider
2185
+
2186
+ provider = resolve_provider(arg)
2187
+ await self.switch_provider(provider, arg)
2188
+ return f"Switched to provider: {arg}"
2189
+
2190
+ async def _cmd_think(self, arg: str) -> str:
2191
+ if not arg or arg.lower() in {"off", "none", "disable"}:
2192
+ await self.set_thinking(None)
2193
+ return "Thinking disabled."
2194
+ await self.set_thinking(arg.split()[0])
2195
+ return f"Thinking set to: {arg.split()[0]}"
2196
+
2197
+ async def _cmd_tier(self, arg: str) -> str:
2198
+ setter = getattr(self._provider, "set_service_tier", None)
2199
+ if setter is None:
2200
+ return "The current provider does not expose service tiers."
2201
+ value = arg.strip().lower()
2202
+ if value not in ("off", "auto", "default", "flex", "priority"):
2203
+ return "Use /tier auto|default|flex|priority|off. Provider pricing applies."
2204
+ try:
2205
+ setter(None if value == "off" else value)
2206
+ except ValueError as error:
2207
+ return str(error)
2208
+ from rlmagent.model.preferences import tier
2209
+
2210
+ tier(self._provider_name, self.model, None if value == "off" else value)
2211
+ return f"Service tier set to {value} for subsequent requests."
2212
+
2213
+ async def _cmd_fast(self, arg: str) -> str:
2214
+ if arg.strip().lower() not in ("on", "off"):
2215
+ return "Use /fast on|off. Priority service pricing applies when enabled."
2216
+ return await self._cmd_tier("priority" if arg.strip().lower() == "on" else "default")
2217
+
2218
+ def _cmd_plan(self, arg: str) -> str:
2219
+ """Toggle plan mode, or set it explicitly with ``on``/``off``."""
2220
+ argument = arg.strip().lower()
2221
+ if not argument:
2222
+ enabled = not self._plan_mode
2223
+ elif argument in {"on", "start", "enable"}:
2224
+ enabled = True
2225
+ elif argument in {"off", "stop", "disable", "exit"}:
2226
+ enabled = False
2227
+ else:
2228
+ return f"Unknown argument: {arg!r}. Use /plan [on|off]."
2229
+
2230
+ if enabled is self._plan_mode:
2231
+ return f"Plan mode already {'on' if enabled else 'off'}."
2232
+
2233
+ self.set_plan_mode(enabled)
2234
+ if enabled:
2235
+ return (
2236
+ "Plan mode on — read-only tools, no edits. "
2237
+ "Use /plan off to resume editing."
2238
+ )
2239
+ return "Plan mode off — editing tools restored."
2240
+
2241
+ def _cmd_tools(self, _arg: str) -> str:
2242
+ """What this session can really call, read from what is loaded."""
2243
+ lines = ["Tools the model is offered: "
2244
+ + (", ".join(sorted(t.name for t in self.tools)) or "(none)")]
2245
+ backend = getattr(self, "backend", None)
2246
+ if backend is not None:
2247
+ lines.append("Functions in a cell: " + ", ".join(backend.function_names()))
2248
+ tree = getattr(self, "agent_tree", None)
2249
+ if tree is not None:
2250
+ from rlmagent.backend import limits_of
2251
+
2252
+ lines.append("Limits: " + ", ".join(f"{k}={v}" for k, v in limits_of(tree).items()))
2253
+ return "\n".join(lines)
2254
+
2255
+ async def _cmd_refine(self, arg: str) -> str:
2256
+ refinement = getattr(self, "refinement", None)
2257
+ if refinement is None:
2258
+ return "Manual refinement is not enabled for this session."
2259
+ return await refinement.command(arg)
2260
+
2261
+ async def _cmd_improve(self, arg: str) -> str:
2262
+ improver = getattr(self, "improver", None)
2263
+ if improver is None:
2264
+ return "The playbook is not available in this run."
2265
+ return await improver.command(arg)
2266
+
2267
+ async def _cmd_compact(self, _arg: str) -> str:
2268
+ try:
2269
+ entry = await self.compact()
2270
+ preview = entry.summary[:100]
2271
+ return f"Compacted. Summary: {preview}..."
2272
+ except ValueError as exc:
2273
+ return str(exc)
2274
+
2275
+ async def _cmd_stats(self, _arg: str) -> str:
2276
+ s = self.usage
2277
+ report = (
2278
+ f"Turns: {s.turn_count} Messages: {s.message_count}\n"
2279
+ f"Input tokens: {s.total_input_tokens:,} "
2280
+ f"Output tokens: {s.total_output_tokens:,}\n"
2281
+ f"Cost: ${s.total_cost_usd:.4f}\n"
2282
+ f"Context: ~{s.estimated_context_tokens:,} / "
2283
+ f"{s.context_limit:,} tokens"
2284
+ )
2285
+ latest, overall = s.latest_cache_hit_rate, s.cache_hit_rate
2286
+ if latest is not None and overall is not None:
2287
+ report += (
2288
+ f"\nCache hit: latest {latest:.0%} session {overall:.0%} "
2289
+ f"(read {s.cache_read_tokens:,}, written {s.cache_write_tokens:,})"
2290
+ )
2291
+ return report
2292
+
2293
+ async def note_playbook_change(self, change: dict) -> None:
2294
+ self.__dict__.setdefault("_playbook_log", []).append(change)
2295
+ await self._append_record(ExtensionRecord(namespace=_PLAYBOOK_NAMESPACE, data=change))
2296
+
2297
+ async def playbook_changes(self) -> list[dict]:
2298
+ if self._vault is None:
2299
+ return list(self.__dict__.get("_playbook_log", []))
2300
+ return [
2301
+ dict(r.data) for r in await self._vault.read_all()
2302
+ if isinstance(r, ExtensionRecord) and r.namespace == _PLAYBOOK_NAMESPACE
2303
+ ]
2304
+
2305
+ async def note_sub_agent(self, session_id: str) -> None:
2306
+ await self._append_record(
2307
+ ExtensionRecord(namespace=_SUB_AGENT_NAMESPACE, data={"session_id": session_id})
2308
+ )
2309
+
2310
+ async def sub_agent_sessions(self) -> list[str]:
2311
+ """Session ids of the sub-agents this session started, in order."""
2312
+ if self._vault is None:
2313
+ return []
2314
+ return [
2315
+ str(r.data["session_id"])
2316
+ for r in await self._vault.read_all()
2317
+ if isinstance(r, ExtensionRecord) and r.namespace == _SUB_AGENT_NAMESPACE
2318
+ ]
2319
+
2320
+ async def set_name(self, title: str) -> None:
2321
+ """Give the session a fixed name, so no model call is spent on titling it."""
2322
+ self._title = title
2323
+ self._named = True
2324
+ # An explicit name is final — stop auto-renaming over it.
2325
+ self._renamed_manually = True
2326
+ await self._append_record(TagRecord(label=title))
2327
+ self._sync_title_to_catalog(title)
2328
+
2329
+ async def _cmd_name(self, arg: str) -> str:
2330
+ if not arg:
2331
+ return f"Session: {self._title or self._session_id}"
2332
+ await self.set_name(arg)
2333
+ return f"Session named: {arg}"
2334
+
2335
+ async def _cmd_export(self, arg: str) -> str:
2336
+ fmt, destination = parse_export_arg(arg)
2337
+ if fmt not in _EXPORT_FORMATS:
2338
+ valid = ", ".join(sorted(_EXPORT_FORMATS))
2339
+ return f"Unknown format {fmt!r}. Choose one of: {valid}"
2340
+
2341
+ content = await self.export(fmt)
2342
+ if destination is None:
2343
+ return content
2344
+
2345
+ try:
2346
+ path = self.export_to_path(content, destination, fmt)
2347
+ except OSError as exc:
2348
+ return f"Could not write export: {exc}"
2349
+ return f"Exported {fmt} to {path}"
2350
+
2351
+ def export_to_path(self, content: str, destination: str, fmt: str) -> Path:
2352
+ """Write *content* to *destination*, resolving directories and ``~``."""
2353
+ target = Path(destination).expanduser()
2354
+ if not target.is_absolute():
2355
+ target = Path(self.cwd) / target
2356
+
2357
+ looks_like_dir = target.is_dir() or destination.endswith(("/", "\\"))
2358
+ if looks_like_dir:
2359
+ target = target / f"{self._session_id}.{_EXPORT_SUFFIX[fmt]}"
2360
+
2361
+ target.parent.mkdir(parents=True, exist_ok=True)
2362
+ target.write_text(content, encoding="utf-8")
2363
+ return target
2364
+
2365
+ async def _cmd_branch(self, arg: str) -> str:
2366
+ summary = arg or None
2367
+ branch_id = await self.branch(summary)
2368
+ return f"Branch created: {branch_id}"
2369
+
2370
+ async def _cmd_rewind(self, arg: str) -> str:
2371
+ """Rewind by row number from ``/rewind``, or by raw entry id."""
2372
+ choices = await self.tree_choices()
2373
+ if not choices:
2374
+ return "Nothing to rewind to — this session has no stored history."
2375
+
2376
+ if not arg:
2377
+ return self._render_tree(choices, header="Rewind to which entry?")
2378
+
2379
+ target = arg.strip()
2380
+ if target.isdigit():
2381
+ index = int(target) - 1
2382
+ if not 0 <= index < len(choices):
2383
+ return f"No entry {target}. Run /rewind to list them."
2384
+ target = choices[index].entry_id
2385
+
2386
+ try:
2387
+ await self.rewind(target)
2388
+ except Exception as exc: # noqa: BLE001
2389
+ return f"Rewind failed: {exc}"
2390
+ return f"Rewound to: {target}"
2391
+
2392
+ async def _cmd_branches(self, _arg: str) -> str:
2393
+ """List fork points recorded in this session."""
2394
+ choices = await self.tree_choices()
2395
+ forks = tuple(c for c in choices if c.label.lstrip().startswith("branch summary:"))
2396
+ if not forks:
2397
+ return "No branches yet. Use /branch to fork the conversation here."
2398
+ return self._render_tree(forks, header="Branches:")
2399
+
2400
+ @staticmethod
2401
+ def _render_tree(
2402
+ choices: tuple[SessionTreeChoice, ...],
2403
+ *,
2404
+ header: str,
2405
+ ) -> str:
2406
+ """Render tree rows as a numbered, selectable list."""
2407
+ lines = [header]
2408
+ for number, choice in enumerate(choices, start=1):
2409
+ marker = "*" if choice.active else " "
2410
+ lines.append(f"{marker}{number:>3}. {choice.label}")
2411
+ lines.append("")
2412
+ lines.append("Pick with /rewind <number>.")
2413
+ return "\n".join(lines)
2414
+
2415
+ async def _cmd_shell(self, arg: str) -> str:
2416
+ if not arg:
2417
+ return "Usage: /shell <command>"
2418
+ entry = await self.run_shell(arg)
2419
+ return f"Exit {entry.exit_code}:\n{entry.output}"
2420
+
2421
+ async def _cmd_create_skill(self, arg: str) -> str:
2422
+ """Write everything after the name into a new project skill."""
2423
+ name, body = parse_create_skill_arg(arg)
2424
+
2425
+ if not name:
2426
+ return (
2427
+ "Usage: /create-skill <name> <instructions>\n"
2428
+ "Everything after the name becomes the skill body "
2429
+ "(Ctrl+J for multi-line)."
2430
+ )
2431
+ if not _SKILL_NAME_RE.match(name):
2432
+ return (
2433
+ f"Invalid skill name {name!r}. Use letters, digits, "
2434
+ "dots, dashes or underscores — no path separators."
2435
+ )
2436
+ if not body:
2437
+ return f"Nothing to save. Add the instructions after '{name}'."
2438
+
2439
+ try:
2440
+ path, existed = self.write_skill(name, body)
2441
+ except OSError as exc:
2442
+ return f"Could not write skill: {exc}"
2443
+
2444
+ count = self.reload_skills()
2445
+ verb = "Updated" if existed else "Created"
2446
+ return (
2447
+ f"{verb} skill '{name}' at {path}\n"
2448
+ f"{count} skill(s) loaded. Invoke it with /skill:{name}"
2449
+ )
2450
+
2451
+ def write_skill(self, name: str, body: str) -> tuple[Path, bool]:
2452
+ """Write ``<cwd>/.rlm-agent/skills/<name>/SKILL.md``."""
2453
+ target = Path(self._cwd) / ".rlm-agent" / "skills" / name / "SKILL.md"
2454
+ existed = target.exists()
2455
+ target.parent.mkdir(parents=True, exist_ok=True)
2456
+ description = derive_skill_description(body)
2457
+ target.write_text(
2458
+ render_skill_file(name, description, body), encoding="utf-8",
2459
+ )
2460
+ return target, existed
2461
+
2462
+ async def _cmd_reload(self, _arg: str) -> str:
2463
+ await self.reload()
2464
+ return "Reloaded."
2465
+
2466
+ async def _cmd_skills(self, arg: str) -> str:
2467
+ """List loaded skills, show one, or reload them from disk."""
2468
+ argument = arg.strip()
2469
+
2470
+ if argument in {"reload", "refresh"}:
2471
+ count = self.reload_skills()
2472
+ return f"Reloaded {count} skill(s)."
2473
+
2474
+ if argument:
2475
+ skill = self.get_skill(argument)
2476
+ if skill is None:
2477
+ known = ", ".join(s.name for s in self._skills) or "none"
2478
+ return f"Unknown skill {argument!r}. Loaded: {known}"
2479
+ return (
2480
+ f"{skill.name} — {skill.description}\n"
2481
+ f"source: {skill.source}\n\n{skill.body}"
2482
+ )
2483
+
2484
+ if not self._skills:
2485
+ return (
2486
+ "No skills loaded.\n"
2487
+ "Create .rlm-agent/skills/<name>/SKILL.md (or .agents/skills/<name>/, "
2488
+ "or the same under ~/), then run /skills reload."
2489
+ )
2490
+ lines = [f"{len(self._skills)} skill(s) loaded:"]
2491
+ for skill in self._skills:
2492
+ lines.append(f" /skill:{skill.name} — {skill.description}")
2493
+ lines.append("")
2494
+ lines.append("Use /skills <name> to view one, /skills reload to re-read.")
2495
+ return "\n".join(lines)
2496
+
2497
+ def system_sections(self) -> list[PromptSection]:
2498
+ """The live system prompt split into attributed sections."""
2499
+ from rlmagent.instructions import prompt_sections
2500
+
2501
+ attributed: list[PromptSection] | None = None
2502
+ for cwd in dict.fromkeys((self._cwd, os.getcwd())):
2503
+ for dated in (True, False):
2504
+ candidate = prompt_sections(
2505
+ tools=self._all_tools, skills=self._skills, cwd=cwd, include_date=dated,
2506
+ )
2507
+ if "\n\n".join(part.text for part in candidate) == self._system:
2508
+ attributed = candidate
2509
+ break
2510
+ if attributed is not None:
2511
+ break
2512
+ if attributed is None:
2513
+ attributed = [PromptSection("System prompt", "runtime-composed", self._system)]
2514
+
2515
+ live = self._harness.settings.system
2516
+ if live != self._system and live.startswith(self._system + "\n\n"):
2517
+ attributed.append(PromptSection(
2518
+ "Plan mode", "plan mode (active)", live[len(self._system) + 2:],
2519
+ ))
2520
+ return attributed
2521
+
2522
+ def _cmd_session(self, _arg: str) -> str:
2523
+ lines = [
2524
+ f"Session: {self._session_id}",
2525
+ f"Title: {self._title or '(untitled)'}",
2526
+ f"Model: {self._model}",
2527
+ f"Provider: {self._provider_name}",
2528
+ f"CWD: {self._cwd}",
2529
+ ]
2530
+ if self._provider_name == ROUTED_PROVIDER:
2531
+ lines.append(f"Hugging Face inference provider: {self._route.describe()}")
2532
+ return "\n".join(lines)
2533
+
2534
+ def _cmd_system(self, _arg: str) -> str:
2535
+ parts = []
2536
+ for number, section in enumerate(self.system_sections(), start=1):
2537
+ parts.append(f"── {number}. {section.title} [{section.origin}]\n{section.text}")
2538
+ return "\n\n".join(parts)
2539
+
2540
+ async def _cmd_diag(self, _arg: str) -> str:
2541
+ return json.dumps(self.diagnostics(), indent=2)
2542
+
2543
+
2544
+ __all__ = [
2545
+ "CodingSession",
2546
+ "SessionStats",
2547
+ ]