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,172 @@
1
+ """Main transcript area — scrollable conversation view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.containers import VerticalScroll
6
+
7
+ from rlmagent.tui.adapter import summarize_call
8
+ from rlmagent.tui.messages import (
9
+ AssistantBlock,
10
+ CommandEcho,
11
+ ErrorBlock,
12
+ KindNotice,
13
+ NoticeBlock,
14
+ ShellBlock,
15
+ SideAnswer,
16
+ ToolCode,
17
+ ToolLine,
18
+ ToolOutput,
19
+ UserBlock,
20
+ should_collapse,
21
+ )
22
+ from rlmagent.tui.steps import StepCard
23
+
24
+
25
+ class TranscriptView(VerticalScroll):
26
+ """Scrollable region holding the conversation."""
27
+
28
+ def __init__(self) -> None:
29
+ super().__init__()
30
+ self._live: AssistantBlock | None = None
31
+ self._tools: dict[str, ToolLine] = {}
32
+ self._cards: dict[tuple[str, int], StepCard] = {}
33
+
34
+ # -- appending ----------------------------------------------------------
35
+
36
+ def add_user(self, text: str) -> None:
37
+ """Append a user turn."""
38
+ self.mount(UserBlock(text))
39
+ self._scroll()
40
+
41
+ def add_notice(self, text: str, *, error: bool = False) -> None:
42
+ """Append a notice (command output, error, cancellation).
43
+
44
+ A multi-line error shows its first line and folds the rest.
45
+ """
46
+ if error and "\n" in text.strip():
47
+ self.mount(ErrorBlock(text))
48
+ else:
49
+ self.mount(NoticeBlock(text, error=error))
50
+ self._scroll()
51
+
52
+ def add_command(self, text: str) -> None:
53
+ """Echo a slash command the user ran."""
54
+ self.mount(CommandEcho(text))
55
+ self._scroll()
56
+
57
+ def add_shell(self, command: str, output: str, exit_code: int | None = None) -> None:
58
+ """A shell result in its box, last lines only."""
59
+ self.mount(ShellBlock(command, output, exit_code))
60
+ self._scroll()
61
+
62
+ def add_side_answer(self, question: str, answer: str) -> None:
63
+ self.mount(SideAnswer(question, answer))
64
+ self._scroll()
65
+
66
+ def add_kind(self, kind: str, text: str) -> None:
67
+ """A labelled notice: compaction, refinement, skill, injected prompt."""
68
+ self.mount(KindNotice(kind, text))
69
+ self._scroll()
70
+
71
+ def start_tool(self, call_id: str, summary: str, code: str = "") -> None:
72
+ """Append a pending tool line, and the code it runs if any."""
73
+ line = ToolLine(call_id, summary)
74
+ self._tools[call_id] = line
75
+ self.mount(line)
76
+ if code:
77
+ self.mount(ToolCode(code))
78
+ self._scroll()
79
+
80
+ def finish_tool(self, call_id: str, output: str, *, is_error: bool) -> None:
81
+ """Resolve a pending tool line and attach large output."""
82
+ line = self._tools.pop(call_id, None)
83
+ if line is not None:
84
+ line.mark_done(is_error=is_error)
85
+ if output and should_collapse(output):
86
+ self.mount(ToolOutput(output))
87
+ self._scroll()
88
+
89
+ def show_step(self, step) -> StepCard:
90
+ """Add a card for a new step, or redraw the card it already has."""
91
+ card = self._cards.get(step.key)
92
+ if card is None:
93
+ card = StepCard(step)
94
+ self._cards[step.key] = card
95
+ if self._live is not None and not self._live.text.strip():
96
+ self.mount(card, before=self._live)
97
+ else:
98
+ self.mount(card)
99
+ else:
100
+ card.refresh_step(step)
101
+ self._scroll()
102
+ return card
103
+
104
+ def new_run(self) -> None:
105
+ """Later steps get new cards; the last run's cards stay as they are."""
106
+ self._cards.clear()
107
+
108
+ @property
109
+ def cards(self) -> list[StepCard]:
110
+ return list(self._cards.values())
111
+
112
+ # -- streaming ----------------------------------------------------------
113
+
114
+ def stream_delta(self, delta: str) -> None:
115
+ """Append streamed assistant text, opening a block on first delta."""
116
+ if self._live is None:
117
+ self._live = AssistantBlock()
118
+ self.mount(self._live)
119
+ self._live.append(delta)
120
+ self._scroll()
121
+
122
+ @property
123
+ def live_text(self) -> str:
124
+ """Text streamed into the open assistant block so far."""
125
+ return self._live.text if self._live is not None else ""
126
+
127
+ def finish_assistant(self, text: str) -> None:
128
+ """Close the live assistant block."""
129
+ if self._live is None:
130
+ if text:
131
+ self.mount(AssistantBlock(text))
132
+ else:
133
+ self._live.finalize(text)
134
+ self._live = None
135
+ self._scroll()
136
+
137
+ def reset(self) -> None:
138
+ """Clear the transcript (used when switching sessions)."""
139
+ self._live = None
140
+ self._tools.clear()
141
+ self._cards.clear()
142
+ self.remove_children()
143
+
144
+ def replay(self, entries) -> None:
145
+ """Repaint the view from a session's stored transcript."""
146
+ self.reset()
147
+ for entry in entries:
148
+ role = getattr(entry, "role", None)
149
+ if role == "user":
150
+ self.mount(UserBlock(entry.text))
151
+ elif role == "assistant":
152
+ for call in entry.tool_calls:
153
+ line = ToolLine(call.id, summarize_call(call.name, call.arguments))
154
+ line.mark_done(is_error=False)
155
+ self.mount(line)
156
+ code = call.arguments.get("code") if call.name == "python" else None
157
+ if isinstance(code, str) and code:
158
+ self.mount(ToolCode(code))
159
+ if entry.text:
160
+ self.mount(AssistantBlock(entry.text))
161
+ elif role == "compactionSummary":
162
+ self.mount(KindNotice("compacted", entry.summary))
163
+ self._scroll()
164
+
165
+ # -- internal -----------------------------------------------------------
166
+
167
+ def _scroll(self) -> None:
168
+ """Keep the newest content in view."""
169
+ self.scroll_end(animate=False)
170
+
171
+
172
+ __all__ = ["TranscriptView"]
@@ -0,0 +1,58 @@
1
+ """Startup question: may this folder's project inputs load?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.widgets import Label, ListItem, ListView
8
+
9
+ from rlmagent.runtime import trust_choices, trust_question
10
+ from rlmagent.trust import Choice, TrustRequest
11
+
12
+
13
+ class _Option(ListItem):
14
+ def __init__(self, label: str, choice: Choice) -> None:
15
+ super().__init__(Label(label))
16
+ self.choice = choice
17
+
18
+
19
+ class TrustDialog(App[Choice | None]):
20
+ """A single list of trust choices under a short explanation."""
21
+
22
+ BINDINGS = [("escape", "dismiss_question", "Exit")]
23
+ CSS = """
24
+ #trust-box { padding: 1 2; height: auto; }
25
+ #trust-text { margin-bottom: 1; }
26
+ #trust-options { height: auto; }
27
+ """
28
+
29
+ def __init__(self, request: TrustRequest) -> None:
30
+ super().__init__()
31
+ self._request = request
32
+
33
+ def compose(self) -> ComposeResult:
34
+ with Vertical(id="trust-box"):
35
+ yield Label(trust_question(self._request), id="trust-text")
36
+ yield ListView(
37
+ *(_Option(label, choice) for choice, label in trust_choices(self._request)),
38
+ id="trust-options",
39
+ )
40
+
41
+ def on_mount(self) -> None:
42
+ self.query_one(ListView).focus()
43
+
44
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
45
+ item = event.item
46
+ if isinstance(item, _Option):
47
+ self.exit(item.choice)
48
+
49
+ def action_dismiss_question(self) -> None:
50
+ self.exit(None)
51
+
52
+
53
+ async def ask_in_dialog(request: TrustRequest) -> Choice | None:
54
+ """Show the dialog and return the chosen answer, or ``None`` if dismissed."""
55
+ return await TrustDialog(request).run_async()
56
+
57
+
58
+ __all__ = ["TrustDialog", "ask_in_dialog"]
@@ -0,0 +1,141 @@
1
+ """Textual client for a worker that owns its conversation and execution."""
2
+
3
+ from textual.app import App, ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.css.query import NoMatches
6
+ from textual.widgets import Footer, Static
7
+ from pydantic import TypeAdapter, ValidationError
8
+
9
+ from rlmagent import worker
10
+ from rlmagent.harness.contracts.transcript import TranscriptEntry
11
+ from rlmagent.tui.adapter import summarize_call
12
+ from rlmagent.tui.prompt import PromptInput
13
+ from rlmagent.tui.transcript import TranscriptView
14
+
15
+ _TRANSCRIPT = TypeAdapter(list[TranscriptEntry])
16
+
17
+
18
+ class WorkerAttachApp(App[None]):
19
+ BINDINGS = [("ctrl+d", "quit", "Detach"), Binding("ctrl+c", "interrupt", "Interrupt", priority=True)]
20
+ CSS = """
21
+ TranscriptView { height: 1fr; }
22
+ PromptInput { height: 4; }
23
+ #worker-status { height: 1; }
24
+ """
25
+
26
+ def __init__(self, name: str) -> None:
27
+ super().__init__()
28
+ self.worker_name = name
29
+ self.cursor = worker.WorkerLog(name, recent_only=True)
30
+ self.history_path = worker.folder(name) / "history.json"
31
+ self.history_modified = None
32
+ self.history_session = None
33
+ self.last_model_reply = None
34
+ #: Whether the worker was alive at the last poll, to report drops and returns.
35
+ self.was_alive: bool | None = None
36
+ #: The worker version already warned about, so the warning shows once.
37
+ self.warned_version: str | None = None
38
+
39
+ def compose(self) -> ComposeResult:
40
+ yield Static("", id="worker-status", markup=False)
41
+ yield TranscriptView()
42
+ yield PromptInput()
43
+ yield Footer()
44
+
45
+ def on_mount(self) -> None:
46
+ self.poll_worker()
47
+ self.set_interval(0.3, self.poll_worker)
48
+
49
+ def poll_worker(self) -> None:
50
+ try:
51
+ transcript = self.query_one(TranscriptView)
52
+ status = self.query_one("#worker-status", Static)
53
+ except NoMatches:
54
+ return # A queued poll can run during teardown.
55
+ try:
56
+ modified = self.history_path.stat().st_mtime_ns
57
+ except OSError:
58
+ modified = None
59
+ history = worker._read(self.history_path) if modified != self.history_modified else None
60
+ if history is not None:
61
+ self.history_modified = modified
62
+ if history and history.get("session") != self.history_session:
63
+ try:
64
+ entries = _TRANSCRIPT.validate_python(history["entries"])
65
+ offset = int(history["log_offset"])
66
+ except (ValidationError, KeyError, TypeError, ValueError):
67
+ self.history_session = history.get("session")
68
+ transcript.add_notice("Could not read the saved session history.", error=True)
69
+ else:
70
+ self.history_session = history["session"]
71
+ self.last_model_reply = None
72
+ self.cursor = worker.WorkerLog(self.worker_name, start_at=max(0, offset))
73
+ transcript.replay(entries)
74
+ for row in self.cursor.read():
75
+ kind, text = row["kind"], str(row["text"])
76
+ if kind == "prompt":
77
+ if transcript.live_text:
78
+ transcript.finish_assistant(transcript.live_text)
79
+ self.last_model_reply = None
80
+ transcript.add_user(text)
81
+ elif kind == "model_delta":
82
+ transcript.stream_delta(text)
83
+ elif kind == "model_end":
84
+ partial = transcript.live_text
85
+ if partial and text.startswith(partial) and len(text) > len(partial):
86
+ transcript.stream_delta(text[len(partial):])
87
+ transcript.finish_assistant(text)
88
+ self.last_model_reply = text
89
+ elif kind == "reply" and self.last_model_reply != text:
90
+ transcript.finish_assistant(text)
91
+ elif kind == "tool_start":
92
+ args = row.get("args", {})
93
+ code = str(args.get("code") or "") if text == "python" else ""
94
+ transcript.start_tool(row["call_id"], summarize_call(text, args), code)
95
+ elif kind == "tool_end":
96
+ transcript.finish_tool(row["call_id"], text, is_error=bool(row["is_error"]))
97
+ elif kind != "reply":
98
+ transcript.add_notice(text, error=kind == "error")
99
+ state = worker.state_of(self.worker_name)
100
+ self.note_connection(transcript, state)
101
+ label = state.get("state", "starting") if state["alive"] else "offline · prompts queue"
102
+ if state.get("state") == "passive" and not (worker.folder(self.worker_name) / "stop").exists():
103
+ label = "passive · prompts queue"
104
+ status.update(f"{self.worker_name} · {label}")
105
+
106
+ def note_connection(self, transcript, state: dict) -> None:
107
+ """Say when the worker drops or comes back, and when its version differs."""
108
+ alive = bool(state.get("alive"))
109
+ if self.was_alive is not None and alive != self.was_alive:
110
+ transcript.add_notice("worker is back; reattached" if alive
111
+ else "worker stopped; prompts will queue until it restarts")
112
+ self.was_alive = alive
113
+ theirs = state.get("version")
114
+ if alive and theirs and theirs != self.warned_version:
115
+ from rlmagent.cli.main import get_version
116
+
117
+ ours = get_version()
118
+ if theirs != ours:
119
+ transcript.add_notice(
120
+ f"This worker runs rlm-agent {theirs}; this window is {ours}. "
121
+ "Restart the worker to use the same version.", error=True)
122
+ self.warned_version = theirs
123
+
124
+ def on_prompt_input_submitted(self, event: PromptInput.Submitted) -> None:
125
+ if event.text in ("/detach", "/quit", "/exit", "/q"):
126
+ self.exit()
127
+ return
128
+ try:
129
+ result = worker.answer_input(self.worker_name, event.text)
130
+ if result is None:
131
+ worker.send(self.worker_name, event.text)
132
+ else:
133
+ self.query_one(TranscriptView).add_notice(result)
134
+ except OSError as error:
135
+ self.query_one(TranscriptView).add_notice(str(error), error=True)
136
+ return
137
+ self.query_one(PromptInput).clear_prompt()
138
+
139
+ def action_interrupt(self) -> None:
140
+ if not worker.interrupt(self.worker_name):
141
+ self.exit()
@@ -0,0 +1 @@
1
+ """rlm-agent ui: terminal rendering (an event-stream consumer)."""
rlmagent/ui/render.py ADDED
@@ -0,0 +1,211 @@
1
+ """Event rendering layer — pure consumers of the harness event stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from enum import Enum
8
+ from typing import Protocol, TextIO
9
+
10
+ from rlmagent.harness.contracts.stream import (
11
+ AgentEvent,
12
+ MessageEndEvent,
13
+ MessageUpdateEvent,
14
+ RetryEvent,
15
+ ToolRunEndEvent,
16
+ ToolRunStartEvent,
17
+ )
18
+ from rlmagent.harness.contracts.transcript import ModelEntry, TextSegment
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Output mode enum
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ class OutputMode(Enum):
26
+ """Supported rendering modes."""
27
+
28
+ TEXT = "text"
29
+ JSON = "json"
30
+ TRANSCRIPT = "transcript"
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Renderer protocol
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ class EventRenderer(Protocol):
39
+ """Minimal contract every renderer implements."""
40
+
41
+ def render(self, event: AgentEvent) -> None: ...
42
+ def finish(self) -> bool: ...
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # JSON renderer
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ class JsonRenderer:
51
+ """Serialises every event as a single JSON line."""
52
+
53
+ __slots__ = ("_out", "_ok")
54
+
55
+ def __init__(self, out: TextIO | None = None) -> None:
56
+ self._out = out if out is not None else sys.stdout
57
+ self._ok = True
58
+
59
+ def render(self, event: AgentEvent) -> None:
60
+ line = json.dumps(event.model_dump(by_alias=True), default=str)
61
+ self._out.write(line + "\n")
62
+ self._out.flush()
63
+ if isinstance(event, MessageEndEvent):
64
+ msg = event.message
65
+ if isinstance(msg, ModelEntry) and msg.stop_reason == "error":
66
+ self._ok = False
67
+
68
+ def finish(self) -> bool:
69
+ return self._ok
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Final-text renderer
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ class FinalTextRenderer:
78
+ """Prints only the final assistant response after the run completes."""
79
+
80
+ __slots__ = ("_out", "_last_text", "_errors")
81
+
82
+ def __init__(self, out: TextIO | None = None) -> None:
83
+ self._out = out if out is not None else sys.stdout
84
+ self._last_text: str | None = None
85
+ self._errors: list[str] = []
86
+
87
+ def render(self, event: AgentEvent) -> None:
88
+ if not isinstance(event, MessageEndEvent):
89
+ return
90
+ msg = event.message
91
+ if not isinstance(msg, ModelEntry):
92
+ return
93
+ text = msg.text.strip()
94
+ if text:
95
+ self._last_text = text
96
+ if msg.error_message:
97
+ self._errors.append(msg.error_message)
98
+
99
+ def finish(self) -> bool:
100
+ if self._errors:
101
+ for err in self._errors:
102
+ self._out.write(f"Error: {err}\n")
103
+ return False
104
+ if self._last_text:
105
+ self._out.write(self._last_text + "\n")
106
+ return True
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Transcript renderer
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ class TranscriptRenderer:
115
+ """Streams assistant text and tool activity as it happens."""
116
+
117
+ __slots__ = ("_out", "_ok", "_prev_len", "_needs_nl")
118
+
119
+ def __init__(self, out: TextIO | None = None) -> None:
120
+ self._out = out if out is not None else sys.stdout
121
+ self._ok = True
122
+ self._prev_len = 0
123
+ self._needs_nl = False
124
+
125
+ def render(self, event: AgentEvent) -> None:
126
+ if isinstance(event, MessageUpdateEvent):
127
+ self._on_update(event)
128
+ elif isinstance(event, MessageEndEvent):
129
+ self._on_end(event)
130
+ elif isinstance(event, RetryEvent):
131
+ self._ensure_newline()
132
+ self._out.write(f"[retry] {event.message}\n")
133
+ self._out.flush()
134
+ elif isinstance(event, ToolRunStartEvent):
135
+ self._ensure_newline()
136
+ self._out.write(f"[tool:{event.tool_name}] running...\n")
137
+ elif isinstance(event, ToolRunEndEvent):
138
+ status = "error" if event.is_error else "done"
139
+ result_preview = event.result.text[:120].replace("\n", " ")
140
+ self._out.write(f"[tool:{event.tool_name}] {status}")
141
+ if result_preview:
142
+ self._out.write(f" — {result_preview}")
143
+ self._out.write("\n")
144
+ self._out.flush()
145
+
146
+ def finish(self) -> bool:
147
+ self._ensure_newline()
148
+ return self._ok
149
+
150
+ # -- private ------------------------------------------------------------
151
+
152
+ def _on_update(self, event: MessageUpdateEvent) -> None:
153
+ msg = event.message
154
+ if not isinstance(msg, ModelEntry):
155
+ return
156
+ full = "".join(
157
+ block.text for block in msg.content if isinstance(block, TextSegment)
158
+ )
159
+ delta = full[self._prev_len:]
160
+ if delta:
161
+ self._out.write(delta)
162
+ self._out.flush()
163
+ self._prev_len = len(full)
164
+ self._needs_nl = not delta.endswith("\n")
165
+
166
+ def _on_end(self, event: MessageEndEvent) -> None:
167
+ msg = event.message
168
+ if not isinstance(msg, ModelEntry):
169
+ return
170
+ # Flush any remaining text not yet streamed
171
+ full = msg.text
172
+ delta = full[self._prev_len:]
173
+ if delta:
174
+ self._out.write(delta)
175
+ self._needs_nl = not delta.endswith("\n")
176
+ self._prev_len = 0
177
+ self._ensure_newline()
178
+ if msg.stop_reason == "error":
179
+ self._ok = False
180
+ if msg.error_message:
181
+ self._out.write(f"Error: {msg.error_message}\n")
182
+ self._out.flush()
183
+
184
+ def _ensure_newline(self) -> None:
185
+ if self._needs_nl:
186
+ self._out.write("\n")
187
+ self._needs_nl = False
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Public API
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def make_renderer(mode: OutputMode, out: TextIO | None = None) -> EventRenderer:
196
+ """Create a renderer for the given output mode."""
197
+ if mode is OutputMode.JSON:
198
+ return JsonRenderer(out)
199
+ if mode is OutputMode.TRANSCRIPT:
200
+ return TranscriptRenderer(out)
201
+ return FinalTextRenderer(out)
202
+
203
+
204
+ __all__ = [
205
+ "EventRenderer",
206
+ "FinalTextRenderer",
207
+ "JsonRenderer",
208
+ "OutputMode",
209
+ "TranscriptRenderer",
210
+ "make_renderer",
211
+ ]
rlmagent/undo.py ADDED
@@ -0,0 +1,109 @@
1
+ """/undo: a hidden git repository that snapshots the workspace before every turn.
2
+
3
+ The repository lives under ~/.rlm-agent/shadow/, never inside the project, so the
4
+ project's own git history is untouched. It honours the project's .gitignore and
5
+ also skips dependency folders, files named like secrets (see EXCLUDES) and files
6
+ over 5 MB, none of which come back on undo. Other files are copied there as they
7
+ are, including any secret under an ordinary name. The last 50 turns can be undone
8
+ after a restart, the last 5 with their variables. The session rolls back saved
9
+ variables itself, on the backend engine, unless they are too large to copy.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ EXCLUDES = """.git/
20
+ node_modules/
21
+ .venv/
22
+ venv/
23
+ __pycache__/
24
+ .rlm-agent/
25
+ .logs/
26
+ .env
27
+ .env.*
28
+ *.pem
29
+ *.key
30
+ id_rsa*
31
+ *.p12
32
+ """
33
+ MAX_FILE_BYTES = 5_000_000
34
+
35
+
36
+ class Shadow:
37
+ def __init__(self, workspace: str | Path, home: Path) -> None:
38
+ self.workspace = Path(workspace).resolve()
39
+ key = hashlib.sha1(str(self.workspace).encode()).hexdigest()[:16]
40
+ self.git_dir = home / "shadow" / f"{key}.git"
41
+ self.reason = ""
42
+ self.available = shutil.which("git") is not None
43
+ if not self.available:
44
+ self.reason = "Undo needs git on PATH."
45
+ elif self.workspace.parent == self.workspace or self.workspace == Path.home().resolve():
46
+ # Snapshotting a whole drive or home folder would copy everything in it.
47
+ self.available = False
48
+ self.reason = (f"Undo is off in {self.workspace}: it is too broad to snapshot. "
49
+ "Start rlm-agent inside a project folder.")
50
+ self.skipped: set[str] = set()
51
+
52
+ def _git(self, *args: str) -> str:
53
+ out = subprocess.run(
54
+ ["git", f"--git-dir={self.git_dir}", f"--work-tree={self.workspace}", *args],
55
+ stdin=subprocess.DEVNULL, capture_output=True, text=True,
56
+ # File names are UTF-8 whatever the console's codepage says.
57
+ encoding="utf-8", errors="replace",
58
+ cwd=self.workspace, timeout=120,
59
+ )
60
+ if out.returncode != 0:
61
+ raise RuntimeError((out.stderr or "").strip() or f"git {args[0]} failed")
62
+ return (out.stdout or "").strip()
63
+
64
+ def _init(self) -> None:
65
+ if (self.git_dir / "HEAD").exists():
66
+ return
67
+ self.git_dir.parent.mkdir(parents=True, exist_ok=True)
68
+ self._git("init", "-q")
69
+ self._git("config", "user.name", "rlm-agent")
70
+ self._git("config", "user.email", "rlm-agent@localhost")
71
+ self._git("config", "core.autocrlf", "false")
72
+ (self.git_dir / "info").mkdir(exist_ok=True)
73
+ (self.git_dir / "info" / "exclude").write_text(EXCLUDES, encoding="utf-8")
74
+
75
+ def _skip_large(self) -> None:
76
+ listed = self._git("ls-files", "--others", "--modified", "--exclude-standard", "-z")
77
+ for rel in filter(None, listed.split("\0")):
78
+ path = self.workspace / rel
79
+ if path.is_file() and path.stat().st_size > MAX_FILE_BYTES:
80
+ self.skipped.add(rel)
81
+ with open(self.git_dir / "info" / "exclude", "a", encoding="utf-8") as f:
82
+ f.write(f"/{rel}\n")
83
+
84
+ def snapshot(self, label: str) -> str:
85
+ """Commit the workspace as it is now; returns the commit id."""
86
+ self._init()
87
+ self._skip_large()
88
+ self._git("add", "-A")
89
+ self._git("commit", "-q", "--allow-empty", "--no-verify", "-m", label[:200])
90
+ return self._git("rev-parse", "HEAD")
91
+
92
+ def diff(self, commit: str, stat: bool = False) -> str:
93
+ """What changed in the workspace since `commit`, as a unified diff or a summary."""
94
+ self.snapshot("before diff")
95
+ return self._git("diff", "--stat" if stat else "--no-color", commit, "HEAD")
96
+
97
+ def label(self, commit: str) -> str:
98
+ return self._git("log", "-1", "--format=%s", commit).removeprefix("before: ")
99
+
100
+ def restore(self, commit: str) -> list[str]:
101
+ """Put the workspace back to `commit` and check it; returns the paths that changed."""
102
+ self.snapshot("before undo")
103
+ changed = self._git("diff", "--name-only", commit, "HEAD").splitlines()
104
+ self._git("read-tree", "-u", "--reset", commit)
105
+ wrong = self._git("diff", "--name-only", commit)
106
+ if wrong:
107
+ raise RuntimeError("these files did not come back: " + ", ".join(wrong.splitlines()[:10]))
108
+ self._git("commit", "-q", "--allow-empty", "--no-verify", "-m", f"undo to {commit[:12]}")
109
+ return changed