rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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()
|
rlmagent/ui/__init__.py
ADDED
|
@@ -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
|