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
rlmagent/tui/sidebar.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Left sidebar — the session switcher, grouped by what each session is doing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.markup import escape
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.widgets import Input, Label, ListItem, ListView, Static
|
|
10
|
+
|
|
11
|
+
from rlmagent.tui.adapter import SessionEntry
|
|
12
|
+
from rlmagent.tui.search_box import SearchBox
|
|
13
|
+
|
|
14
|
+
#: Section order and headings. Each session sits under exactly one.
|
|
15
|
+
SECTIONS: tuple[tuple[str, str], ...] = (
|
|
16
|
+
("running", "Running"),
|
|
17
|
+
("idle", "Idle"),
|
|
18
|
+
("inactive", "Inactive"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionItem(ListItem):
|
|
23
|
+
"""A session row. Identity is carried as an attribute, not a widget id."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, label: str, session_id: str | None) -> None:
|
|
26
|
+
super().__init__(Label(label, markup=False))
|
|
27
|
+
self.session_id = session_id
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_new_action(self) -> bool:
|
|
31
|
+
"""Whether this row creates a new session."""
|
|
32
|
+
return self.session_id is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SectionHeading(ListItem):
|
|
36
|
+
"""A section title inside the list; it cannot be chosen."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, title: str, count: int) -> None:
|
|
39
|
+
super().__init__(Label(f"{title} ({count})", markup=False), classes="session-heading")
|
|
40
|
+
self.disabled = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SessionList(ListView):
|
|
44
|
+
"""Selectable list of sessions, grouped by status, newest first in each group."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
super().__init__(id="session-list")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def matches(entry: SessionEntry, query: str) -> bool:
|
|
51
|
+
"""Whether *entry* fits a search by title or id, case-insensitively."""
|
|
52
|
+
query = query.strip().lower()
|
|
53
|
+
return not query or query in entry.label.lower() or query in entry.session_id.lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Sidebar(Vertical):
|
|
57
|
+
"""Fixed-width column holding the session switcher."""
|
|
58
|
+
|
|
59
|
+
class SwitchRequested(Message):
|
|
60
|
+
"""User picked an existing session."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, session_id: str) -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self.session_id = session_id
|
|
65
|
+
|
|
66
|
+
class NewRequested(Message):
|
|
67
|
+
"""User asked for a fresh session."""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
#: What the list was last built from, so an unchanged refresh only restyles.
|
|
72
|
+
self._shape: tuple = ()
|
|
73
|
+
self._entries: list[SessionEntry] = []
|
|
74
|
+
#: Active session id at the last repaint, so the cursor only moves
|
|
75
|
+
#: when the active session actually changes.
|
|
76
|
+
self._active: str | None = None
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
yield Label("Sessions", id="sidebar-title")
|
|
80
|
+
yield SearchBox(placeholder="search sessions", id="session-search")
|
|
81
|
+
yield SessionList()
|
|
82
|
+
yield Static("", id="task-panel")
|
|
83
|
+
|
|
84
|
+
def show_tasks(self, items: list[dict]) -> None:
|
|
85
|
+
"""The agent's task list under the sessions; hidden while it is empty."""
|
|
86
|
+
panel = self.query_one("#task-panel", Static)
|
|
87
|
+
lines = [("✓ " if item["done"] else "· ") + escape(item["text"]) for item in items]
|
|
88
|
+
panel.update("Tasks\n" + "\n".join(lines) if lines else "")
|
|
89
|
+
panel.display = bool(lines)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def query_text(self) -> str:
|
|
93
|
+
return self.query_one("#session-search", Input).value
|
|
94
|
+
|
|
95
|
+
async def show(self, entries: list[SessionEntry]) -> None:
|
|
96
|
+
"""Repaint the list, highlighting the active session."""
|
|
97
|
+
self._entries = list(entries)
|
|
98
|
+
await self._rebuild()
|
|
99
|
+
|
|
100
|
+
async def _rebuild(self) -> None:
|
|
101
|
+
listing = self.query_one(SessionList)
|
|
102
|
+
query = self.query_text
|
|
103
|
+
shown = [e for e in self._entries if matches(e, query)]
|
|
104
|
+
active = next((e.session_id for e in self._entries if e.is_active), None)
|
|
105
|
+
shape = (query, tuple((e.session_id, e.status) for e in shown))
|
|
106
|
+
|
|
107
|
+
if shape == self._shape:
|
|
108
|
+
self._restyle(listing, shown)
|
|
109
|
+
if active != self._active:
|
|
110
|
+
self._active = active
|
|
111
|
+
listing.index = self._active_index(listing)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# clear() removes children lazily, so await it or the appends race
|
|
115
|
+
# still-mounted widgets.
|
|
116
|
+
await listing.clear()
|
|
117
|
+
if not query:
|
|
118
|
+
await listing.append(SessionItem("+ New session", None))
|
|
119
|
+
for status, title in SECTIONS:
|
|
120
|
+
group = [e for e in shown if e.status == status]
|
|
121
|
+
if not group:
|
|
122
|
+
continue
|
|
123
|
+
await listing.append(SectionHeading(title, len(group)))
|
|
124
|
+
for entry in group:
|
|
125
|
+
await listing.append(SessionItem(self._label_for(entry), entry.session_id))
|
|
126
|
+
if query and not shown:
|
|
127
|
+
await listing.append(SectionHeading("No match", 0))
|
|
128
|
+
self._shape = shape
|
|
129
|
+
self._active = active
|
|
130
|
+
self._restyle(listing, shown)
|
|
131
|
+
listing.index = self._active_index(listing)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _label_for(entry: SessionEntry) -> str:
|
|
135
|
+
marker = "●" if entry.is_active else " "
|
|
136
|
+
return f"{marker} {entry.label}"
|
|
137
|
+
|
|
138
|
+
def _active_index(self, listing: SessionList) -> int:
|
|
139
|
+
for i, child in enumerate(listing.children):
|
|
140
|
+
if isinstance(child, SessionItem) and child.has_class("active-session"):
|
|
141
|
+
return i
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
def _restyle(self, listing: SessionList, entries: list[SessionEntry]) -> None:
|
|
145
|
+
"""Refresh labels and the active marker without touching the cursor."""
|
|
146
|
+
by_id = {e.session_id: e for e in entries}
|
|
147
|
+
for child in listing.children:
|
|
148
|
+
if not isinstance(child, SessionItem) or child.is_new_action:
|
|
149
|
+
continue
|
|
150
|
+
entry = by_id.get(child.session_id or "")
|
|
151
|
+
if entry is None:
|
|
152
|
+
continue
|
|
153
|
+
child.set_class(entry.is_active, "active-session")
|
|
154
|
+
child.query(Label).first().update(self._label_for(entry))
|
|
155
|
+
|
|
156
|
+
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
157
|
+
if event.input.id == "session-search":
|
|
158
|
+
event.stop()
|
|
159
|
+
await self._rebuild()
|
|
160
|
+
|
|
161
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
162
|
+
"""Enter in the search box opens the first match."""
|
|
163
|
+
if event.input.id != "session-search":
|
|
164
|
+
return
|
|
165
|
+
event.stop()
|
|
166
|
+
first = next((e for e in self._entries if matches(e, event.value)), None)
|
|
167
|
+
if first is not None and not first.is_active:
|
|
168
|
+
self.post_message(self.SwitchRequested(first.session_id))
|
|
169
|
+
|
|
170
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
171
|
+
"""Translate a row activation into an intent message."""
|
|
172
|
+
event.stop()
|
|
173
|
+
item = event.item
|
|
174
|
+
if not isinstance(item, SessionItem):
|
|
175
|
+
return
|
|
176
|
+
if item.is_new_action:
|
|
177
|
+
self.post_message(self.NewRequested())
|
|
178
|
+
else:
|
|
179
|
+
self.post_message(self.SwitchRequested(item.session_id or ""))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["SECTIONS", "SectionHeading", "SessionItem", "SessionList", "Sidebar", "matches"]
|
rlmagent/tui/status.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Footer status bar: provider, model, session, context usage, git branch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from rlmagent.tui.adapter import StatusSnapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StatusBar(Static):
|
|
11
|
+
"""One-line status readout pinned above the keybinding footer."""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
super().__init__("", id="status-bar")
|
|
15
|
+
self._line = ""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def line(self) -> str:
|
|
19
|
+
"""The markup currently displayed."""
|
|
20
|
+
return self._line
|
|
21
|
+
|
|
22
|
+
def show(self, snapshot: StatusSnapshot, *, busy: bool = False, goal: str = "") -> None:
|
|
23
|
+
"""Render *snapshot*."""
|
|
24
|
+
parts = [
|
|
25
|
+
snapshot.provider,
|
|
26
|
+
snapshot.model,
|
|
27
|
+
f"ctx {snapshot.context_percent}%",
|
|
28
|
+
]
|
|
29
|
+
if snapshot.input_tokens or snapshot.output_tokens or snapshot.cost:
|
|
30
|
+
parts.append(f"{snapshot.input_tokens:,} in, {snapshot.cached_tokens:,} cached, "
|
|
31
|
+
f"{snapshot.output_tokens:,} out, ${snapshot.cost:.4f}")
|
|
32
|
+
if snapshot.budget:
|
|
33
|
+
parts.append(snapshot.budget)
|
|
34
|
+
if snapshot.branch:
|
|
35
|
+
parts.append(f"⎇ {snapshot.branch}")
|
|
36
|
+
if snapshot.plan_mode:
|
|
37
|
+
parts.append("PLAN")
|
|
38
|
+
if goal:
|
|
39
|
+
short = goal if len(goal) <= 40 else goal[:39] + "…"
|
|
40
|
+
parts.append(f"◎ {short}")
|
|
41
|
+
if busy:
|
|
42
|
+
parts.append("■ generating — press Esc to stop")
|
|
43
|
+
self._line = " · ".join(parts)
|
|
44
|
+
# Highlight the whole bar while a generation can be stopped.
|
|
45
|
+
self.set_class(busy, "busy")
|
|
46
|
+
self.update(self._line)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["StatusBar"]
|
rlmagent/tui/steps.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Step cards: one block per agent step, built from the engine's run events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.message import Message
|
|
11
|
+
from textual.widgets import Collapsible, Static
|
|
12
|
+
|
|
13
|
+
from rlmagent.tui.messages import highlighted_code
|
|
14
|
+
|
|
15
|
+
SHOWN_OUTPUT_LINES = 6
|
|
16
|
+
MAX_OUTPUT_CHARS = 20_000
|
|
17
|
+
|
|
18
|
+
GENERATING, RUNNING, DONE, FAILED = "generating", "running", "done", "failed"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Step:
|
|
23
|
+
"""What a card shows for one step of one agent."""
|
|
24
|
+
|
|
25
|
+
agent: str
|
|
26
|
+
number: int
|
|
27
|
+
depth: int = 0
|
|
28
|
+
model: str = ""
|
|
29
|
+
state: str = GENERATING
|
|
30
|
+
code: str = ""
|
|
31
|
+
output: str = ""
|
|
32
|
+
tokens: int = 0
|
|
33
|
+
cost: float | None = None
|
|
34
|
+
started: float | None = None
|
|
35
|
+
ended: float | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def key(self) -> tuple[str, int]:
|
|
39
|
+
return (self.agent, self.number)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def short(self) -> str:
|
|
43
|
+
return short_id(self.agent)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def seconds(self) -> float | None:
|
|
47
|
+
if self.started is None or self.ended is None:
|
|
48
|
+
return None
|
|
49
|
+
return max(0.0, self.ended - self.started)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def short_id(run_id: str) -> str:
|
|
53
|
+
"""Six characters that tell agents apart; the root keeps its name."""
|
|
54
|
+
if run_id == "root" or len(run_id) <= 6:
|
|
55
|
+
return run_id
|
|
56
|
+
return run_id[-6:]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Agent:
|
|
61
|
+
run_id: str
|
|
62
|
+
parent: str | None = None
|
|
63
|
+
depth: int = 0
|
|
64
|
+
model: str = ""
|
|
65
|
+
status: str = RUNNING
|
|
66
|
+
steps: list[int] = field(default_factory=list)
|
|
67
|
+
child_id: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _moment(value) -> float | None:
|
|
71
|
+
"""Seconds since the epoch, from a number or the ISO text the engine records."""
|
|
72
|
+
if value is None or isinstance(value, (int, float)):
|
|
73
|
+
return value
|
|
74
|
+
from datetime import datetime
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return datetime.fromisoformat(str(value)).timestamp()
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _usage(value) -> tuple[int, float | None]:
|
|
83
|
+
if value is None:
|
|
84
|
+
return 0, None
|
|
85
|
+
if isinstance(value, dict):
|
|
86
|
+
tokens = value.get("total_tokens") or (
|
|
87
|
+
(value.get("prompt_tokens") or 0) + (value.get("completion_tokens") or 0))
|
|
88
|
+
return int(tokens or 0), value.get("cost")
|
|
89
|
+
tokens = getattr(value, "total_tokens", 0) or (
|
|
90
|
+
(getattr(value, "prompt_tokens", 0) or 0) + (getattr(value, "completion_tokens", 0) or 0))
|
|
91
|
+
return int(tokens or 0), getattr(value, "cost", None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class StepBook:
|
|
95
|
+
"""Folds engine events into steps and agents. Holds no widgets."""
|
|
96
|
+
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
self.steps: dict[tuple[str, int], Step] = {}
|
|
99
|
+
self.agents: dict[str, Agent] = {}
|
|
100
|
+
self.order: list[tuple[str, int]] = []
|
|
101
|
+
self.child_names: dict[str, str] = {}
|
|
102
|
+
self.child_notes: dict[str, str] = {}
|
|
103
|
+
|
|
104
|
+
def clear(self) -> None:
|
|
105
|
+
self.steps.clear()
|
|
106
|
+
self.agents.clear()
|
|
107
|
+
self.order.clear()
|
|
108
|
+
self.child_names.clear()
|
|
109
|
+
self.child_notes.clear()
|
|
110
|
+
|
|
111
|
+
def feed(self, event: dict) -> Step | None:
|
|
112
|
+
"""Apply one event; return the step it changed, if it changed one worth showing."""
|
|
113
|
+
kind = event.get("kind")
|
|
114
|
+
run_id = event.get("run_id") or "root"
|
|
115
|
+
child_id = event.get("child_id")
|
|
116
|
+
if kind == "run_started":
|
|
117
|
+
agent = self.agents.setdefault(run_id, Agent(run_id))
|
|
118
|
+
agent.parent = event.get("parent_run_id")
|
|
119
|
+
agent.depth = int(event.get("depth") or 0)
|
|
120
|
+
agent.model = str(event.get("model") or "")
|
|
121
|
+
agent.status = RUNNING
|
|
122
|
+
if child_id:
|
|
123
|
+
agent.child_id = child_id
|
|
124
|
+
return None
|
|
125
|
+
if kind == "child_bound" and child_id:
|
|
126
|
+
self.agents.setdefault(run_id, Agent(run_id)).child_id = child_id
|
|
127
|
+
if kind in ("child_bound", "child_named") and child_id and event.get("name"):
|
|
128
|
+
self.child_names[child_id] = event["name"]
|
|
129
|
+
if kind == "child_progress" and child_id:
|
|
130
|
+
self.child_notes[child_id] = " ".join(str(event.get("text", ""))[:500].splitlines())
|
|
131
|
+
return None
|
|
132
|
+
if kind == "child_status":
|
|
133
|
+
if run_id in self.agents:
|
|
134
|
+
self.agents[run_id].status = event["status"]
|
|
135
|
+
return None
|
|
136
|
+
if kind in ("run_completed", "run_failed"):
|
|
137
|
+
agent = self.agents.get(run_id)
|
|
138
|
+
if agent is not None:
|
|
139
|
+
agent.status = DONE if kind == "run_completed" else FAILED
|
|
140
|
+
return None
|
|
141
|
+
number = event.get("step")
|
|
142
|
+
if not isinstance(number, int) or number <= 0:
|
|
143
|
+
return None # step 0 only loads the context
|
|
144
|
+
step = self._step(run_id, number)
|
|
145
|
+
if kind == "step_started":
|
|
146
|
+
step.state = GENERATING
|
|
147
|
+
step.started = _moment(event.get("started") or event.get("timestamp"))
|
|
148
|
+
elif kind == "code_generated":
|
|
149
|
+
step.code = str(event.get("code") or "")
|
|
150
|
+
step.state = RUNNING if step.code else FAILED
|
|
151
|
+
elif kind == "output_received":
|
|
152
|
+
step.output = str(event.get("output") or "")[-MAX_OUTPUT_CHARS:]
|
|
153
|
+
if event.get("error"):
|
|
154
|
+
step.state = FAILED
|
|
155
|
+
elif kind == "step_completed":
|
|
156
|
+
step.tokens, step.cost = _usage(event.get("usage"))
|
|
157
|
+
step.state = FAILED if event.get("error") else DONE
|
|
158
|
+
step.ended = _moment(event.get("ended") or event.get("timestamp"))
|
|
159
|
+
else:
|
|
160
|
+
return None
|
|
161
|
+
return step
|
|
162
|
+
|
|
163
|
+
def _step(self, run_id: str, number: int) -> Step:
|
|
164
|
+
key = (run_id, number)
|
|
165
|
+
step = self.steps.get(key)
|
|
166
|
+
if step is None:
|
|
167
|
+
agent = self.agents.get(run_id) or Agent(run_id)
|
|
168
|
+
self.agents.setdefault(run_id, agent)
|
|
169
|
+
step = Step(run_id, number, agent.depth, agent.model)
|
|
170
|
+
self.steps[key] = step
|
|
171
|
+
self.order.append(key)
|
|
172
|
+
agent.steps.append(number)
|
|
173
|
+
return step
|
|
174
|
+
|
|
175
|
+
def totals(self) -> dict:
|
|
176
|
+
steps = self.steps.values()
|
|
177
|
+
costs = [s.cost for s in steps if s.cost is not None]
|
|
178
|
+
return {
|
|
179
|
+
"steps": len(self.steps),
|
|
180
|
+
"tokens": sum(s.tokens for s in steps),
|
|
181
|
+
"cost": sum(costs) if costs else None,
|
|
182
|
+
"agents": len(self.agents),
|
|
183
|
+
"running": sum(1 for a in self.agents.values() if a.status == RUNNING),
|
|
184
|
+
"depth": max((a.depth for a in self.agents.values()), default=0),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def header_text(step: Step) -> str:
|
|
189
|
+
"""The card's title line, e.g. ``RLM d0 · root · step 1 · running``."""
|
|
190
|
+
return f"RLM d{step.depth} · {step.short} · step {step.number} · {step.state}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def footer_text(step: Step) -> str:
|
|
194
|
+
"""Tokens, cost and time under a finished step."""
|
|
195
|
+
parts = [f"{step.tokens / 1000:.1f}k tokens" if step.tokens >= 1000 else f"{step.tokens} tokens"]
|
|
196
|
+
parts.append("cost unknown" if step.cost is None else f"${step.cost:.4f}")
|
|
197
|
+
if step.seconds is not None:
|
|
198
|
+
parts.append(f"{step.seconds:.1f}s")
|
|
199
|
+
return " · ".join(parts)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def output_preview(output: str) -> tuple[str, int]:
|
|
203
|
+
"""The first lines of output and how many were left out."""
|
|
204
|
+
lines = output.rstrip("\n").splitlines()
|
|
205
|
+
hidden = max(0, len(lines) - SHOWN_OUTPUT_LINES)
|
|
206
|
+
return "\n".join(lines[:SHOWN_OUTPUT_LINES]), hidden
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class StepCard(Vertical, can_focus=True):
|
|
210
|
+
"""One step on screen: title, code, output preview, usage. Enter or a click opens it."""
|
|
211
|
+
|
|
212
|
+
BINDINGS = [("enter", "inspect", "Open step"), ("space", "toggle_details", "Toggle details")]
|
|
213
|
+
|
|
214
|
+
class Inspect(Message):
|
|
215
|
+
def __init__(self, step: Step) -> None:
|
|
216
|
+
super().__init__()
|
|
217
|
+
self.step = step
|
|
218
|
+
|
|
219
|
+
def action_inspect(self) -> None:
|
|
220
|
+
self.post_message(self.Inspect(self.step))
|
|
221
|
+
|
|
222
|
+
def action_toggle_details(self) -> None:
|
|
223
|
+
if self.step.state in (DONE, FAILED):
|
|
224
|
+
self._collapsed = not self._collapsed
|
|
225
|
+
self.refresh_step(self.step)
|
|
226
|
+
|
|
227
|
+
def on_click(self) -> None:
|
|
228
|
+
self.action_inspect()
|
|
229
|
+
|
|
230
|
+
def __init__(self, step: Step) -> None:
|
|
231
|
+
super().__init__(classes="step-card")
|
|
232
|
+
self.step = step
|
|
233
|
+
self._collapsed = False
|
|
234
|
+
self.styles.margin = (0, 0, 0, 2 * step.depth)
|
|
235
|
+
self._head = Static(classes="step-head")
|
|
236
|
+
self._code = Static(classes="step-code")
|
|
237
|
+
self._out = Static(classes="step-output")
|
|
238
|
+
self._more: Collapsible | None = None
|
|
239
|
+
self._foot = Static(classes="step-foot")
|
|
240
|
+
|
|
241
|
+
def compose(self):
|
|
242
|
+
yield self._head
|
|
243
|
+
yield self._code
|
|
244
|
+
yield self._out
|
|
245
|
+
yield self._foot
|
|
246
|
+
|
|
247
|
+
def on_mount(self) -> None:
|
|
248
|
+
self.refresh_step(self.step)
|
|
249
|
+
|
|
250
|
+
def refresh_step(self, step: Step) -> None:
|
|
251
|
+
self.step = step
|
|
252
|
+
self._head.update(Text(header_text(step)))
|
|
253
|
+
self.set_class(step.state == FAILED, "step-failed")
|
|
254
|
+
self.set_class(step.state in (GENERATING, RUNNING), "step-live")
|
|
255
|
+
self._code.update(highlighted_code(step.code) if step.code else "")
|
|
256
|
+
self._code.display = bool(step.code) and not self._collapsed
|
|
257
|
+
shown, hidden = output_preview(step.output)
|
|
258
|
+
self._out.update(escape(shown) if shown else "")
|
|
259
|
+
self._out.display = bool(shown) and not self._collapsed
|
|
260
|
+
if hidden and self._more is None:
|
|
261
|
+
self._more = Collapsible(Static(escape(step.output)), collapsed=True,
|
|
262
|
+
title=f"{hidden} more lines")
|
|
263
|
+
self.mount(self._more, before=self._foot)
|
|
264
|
+
elif self._more is not None:
|
|
265
|
+
self._more.title = f"{hidden} more lines"
|
|
266
|
+
self._more.query_one(Static).update(escape(step.output))
|
|
267
|
+
if self._more is not None:
|
|
268
|
+
self._more.display = bool(hidden) and not self._collapsed
|
|
269
|
+
done = step.state in (DONE, FAILED)
|
|
270
|
+
self._foot.update(footer_text(step) if done else "")
|
|
271
|
+
self._foot.display = done
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
__all__ = [
|
|
276
|
+
"Step",
|
|
277
|
+
"StepBook",
|
|
278
|
+
"StepCard",
|
|
279
|
+
"footer_text",
|
|
280
|
+
"header_text",
|
|
281
|
+
"output_preview",
|
|
282
|
+
"short_id",
|
|
283
|
+
]
|