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,57 @@
|
|
|
1
|
+
"""Modal that asks before the sandbox writes a file or runs a command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.syntax import Syntax
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Label, ListItem, ListView, Static
|
|
10
|
+
|
|
11
|
+
from rlmagent.agents.approval import ALWAYS, AUTO, DENY, ONCE, Request
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Choice(ListItem):
|
|
15
|
+
def __init__(self, text: str, value: str) -> None:
|
|
16
|
+
super().__init__(Label(text))
|
|
17
|
+
self.value = value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApprovalScreen(ModalScreen[str]):
|
|
21
|
+
BINDINGS = [("escape", "deny", "Deny"), ("n", "deny", "Deny"), ("y", "once", "Allow once"),
|
|
22
|
+
("shift+tab", "auto", "Auto-accept")]
|
|
23
|
+
|
|
24
|
+
def __init__(self, request: Request) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.request = request
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
always = "this command" if self.request.kind == "run" else "this file"
|
|
30
|
+
with Vertical(id="approval-dialog"):
|
|
31
|
+
yield Label("The agent wants to:", id="approval-title")
|
|
32
|
+
text = self.request.describe()
|
|
33
|
+
body = Syntax(text, "diff", theme="monokai", word_wrap=True,
|
|
34
|
+
background_color="default") if self.request.preview else text
|
|
35
|
+
yield Static(body, id="approval-body", markup=False)
|
|
36
|
+
yield ListView(
|
|
37
|
+
_Choice("Allow once", ONCE),
|
|
38
|
+
_Choice(f"Always allow {always} this session", ALWAYS),
|
|
39
|
+
_Choice("Allow, and auto-accept the rest of this session", AUTO),
|
|
40
|
+
_Choice("Deny", DENY),
|
|
41
|
+
id="approval-list",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def on_mount(self) -> None:
|
|
45
|
+
self.query_one("#approval-list", ListView).focus()
|
|
46
|
+
|
|
47
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
48
|
+
self.dismiss(event.item.value)
|
|
49
|
+
|
|
50
|
+
def action_once(self) -> None:
|
|
51
|
+
self.dismiss(ONCE)
|
|
52
|
+
|
|
53
|
+
def action_auto(self) -> None:
|
|
54
|
+
self.dismiss(AUTO)
|
|
55
|
+
|
|
56
|
+
def action_deny(self) -> None:
|
|
57
|
+
self.dismiss(DENY)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""A small modal list: pick one value with the arrows and Enter, Esc to leave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Label, ListItem, ListView
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Option(ListItem):
|
|
12
|
+
def __init__(self, value: str, current: bool) -> None:
|
|
13
|
+
super().__init__(Label(("● " if current else " ") + value, markup=False))
|
|
14
|
+
self.value = value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChoiceScreen(ModalScreen[str | None]):
|
|
18
|
+
"""Returns the chosen value, or ``None`` when dismissed."""
|
|
19
|
+
|
|
20
|
+
BINDINGS = [("escape", "leave", "Cancel")]
|
|
21
|
+
|
|
22
|
+
def __init__(self, title: str, options: list[str], current: str | None = None) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.title_text = title
|
|
25
|
+
self.options = list(options)
|
|
26
|
+
self.current = current
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
with Vertical(id="approval-dialog"):
|
|
30
|
+
yield Label(self.title_text, id="approval-title")
|
|
31
|
+
yield ListView(*[_Option(o, o == self.current) for o in self.options],
|
|
32
|
+
id="choice-list")
|
|
33
|
+
|
|
34
|
+
def on_mount(self) -> None:
|
|
35
|
+
view = self.query_one("#choice-list", ListView)
|
|
36
|
+
if self.current in self.options:
|
|
37
|
+
view.index = self.options.index(self.current)
|
|
38
|
+
view.focus()
|
|
39
|
+
|
|
40
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
41
|
+
self.dismiss(event.item.value)
|
|
42
|
+
|
|
43
|
+
def action_leave(self) -> None:
|
|
44
|
+
self.dismiss(None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["ChoiceScreen"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Slash-command autocomplete bar shown above the composer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
from rlmagent.tui.messages import ACCENT
|
|
12
|
+
|
|
13
|
+
_MAX_VISIBLE = 6
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Completion:
|
|
18
|
+
"""One selectable slash command."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
description: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def insertion(self) -> str:
|
|
25
|
+
"""Text that replaces the composer contents when chosen."""
|
|
26
|
+
return f"/{self.name} "
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def match_commands(
|
|
30
|
+
text: str, commands: tuple[tuple[str, str], ...],
|
|
31
|
+
) -> list[Completion]:
|
|
32
|
+
"""Return commands matching *text*, or ``[]`` when not completing."""
|
|
33
|
+
if not text.startswith("/") or " " in text:
|
|
34
|
+
return []
|
|
35
|
+
fragment = text[1:].lower()
|
|
36
|
+
matches = [
|
|
37
|
+
Completion(name, description)
|
|
38
|
+
for name, description in commands
|
|
39
|
+
if name.startswith(fragment)
|
|
40
|
+
]
|
|
41
|
+
return matches[:_MAX_VISIBLE]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CompletionBar(Vertical):
|
|
45
|
+
"""Dropdown of slash-command candidates."""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
super().__init__(id="completion-bar")
|
|
49
|
+
self._items: list[Completion] = []
|
|
50
|
+
self._index = 0
|
|
51
|
+
self.display = False
|
|
52
|
+
|
|
53
|
+
# -- state --------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def items(self) -> list[Completion]:
|
|
57
|
+
"""Currently offered completions."""
|
|
58
|
+
return self._items
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def active(self) -> bool:
|
|
62
|
+
"""Whether the bar is showing anything."""
|
|
63
|
+
return bool(self._items)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def selected(self) -> Completion | None:
|
|
67
|
+
"""The highlighted completion, if any."""
|
|
68
|
+
if not self._items:
|
|
69
|
+
return None
|
|
70
|
+
return self._items[self._index % len(self._items)]
|
|
71
|
+
|
|
72
|
+
# -- updates ------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def offer(self, items: list[Completion]) -> None:
|
|
75
|
+
"""Replace the candidate list and repaint."""
|
|
76
|
+
self._items = items
|
|
77
|
+
self._index = 0
|
|
78
|
+
self.display = bool(items)
|
|
79
|
+
self._repaint()
|
|
80
|
+
|
|
81
|
+
def dismiss(self) -> None:
|
|
82
|
+
"""Hide the bar."""
|
|
83
|
+
self.offer([])
|
|
84
|
+
|
|
85
|
+
def move(self, delta: int) -> None:
|
|
86
|
+
"""Move the highlight, wrapping at both ends."""
|
|
87
|
+
if not self._items:
|
|
88
|
+
return
|
|
89
|
+
self._index = (self._index + delta) % len(self._items)
|
|
90
|
+
self._repaint()
|
|
91
|
+
|
|
92
|
+
# -- rendering ----------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def compose(self) -> ComposeResult:
|
|
95
|
+
yield Static("", id="completion-body")
|
|
96
|
+
|
|
97
|
+
def _repaint(self) -> None:
|
|
98
|
+
try:
|
|
99
|
+
body = self.query_one("#completion-body", Static)
|
|
100
|
+
except Exception: # noqa: BLE001 - not mounted yet
|
|
101
|
+
return
|
|
102
|
+
if not self._items:
|
|
103
|
+
body.update("")
|
|
104
|
+
return
|
|
105
|
+
lines = []
|
|
106
|
+
for i, item in enumerate(self._items):
|
|
107
|
+
if i == self._index:
|
|
108
|
+
lines.append(
|
|
109
|
+
f"[{ACCENT}]› /{item.name:<10} {item.description}[/{ACCENT}]"
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
lines.append(f" /{item.name:<10} {item.description}")
|
|
113
|
+
body.update("\n".join(lines))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["Completion", "CompletionBar", "match_commands"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Public widget surface for rlm-agent's TUI."""
|
|
2
|
+
|
|
3
|
+
from rlmagent.tui.messages import (
|
|
4
|
+
AssistantBlock,
|
|
5
|
+
NoticeBlock,
|
|
6
|
+
ToolLine,
|
|
7
|
+
ToolOutput,
|
|
8
|
+
UserBlock,
|
|
9
|
+
)
|
|
10
|
+
from rlmagent.tui.prompt import PromptInput
|
|
11
|
+
from rlmagent.tui.sidebar import Sidebar
|
|
12
|
+
from rlmagent.tui.status import StatusBar
|
|
13
|
+
from rlmagent.tui.transcript import TranscriptView
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AssistantBlock",
|
|
17
|
+
"NoticeBlock",
|
|
18
|
+
"PromptInput",
|
|
19
|
+
"Sidebar",
|
|
20
|
+
"StatusBar",
|
|
21
|
+
"ToolLine",
|
|
22
|
+
"ToolOutput",
|
|
23
|
+
"TranscriptView",
|
|
24
|
+
"UserBlock",
|
|
25
|
+
]
|
rlmagent/tui/composer.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The composer row: prompt marker, input, and a model/effort readout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from rlmagent.tui.messages import RLM_AGENT_MARK
|
|
10
|
+
from rlmagent.tui.prompt import PromptInput
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModelBadge(Static):
|
|
14
|
+
"""Right-hand readout showing the active model and effort level."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
super().__init__("", id="model-badge")
|
|
18
|
+
self._line = ""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def line(self) -> str:
|
|
22
|
+
"""The text currently displayed."""
|
|
23
|
+
return self._line
|
|
24
|
+
|
|
25
|
+
def show(self, model: str, effort: str, auto_accept: bool = False) -> None:
|
|
26
|
+
"""Render *model* and *effort*, and say so when nothing asks before changing."""
|
|
27
|
+
self._line = ("auto-accept · " if auto_accept else "") + f"{model} · {effort}"
|
|
28
|
+
self.update(self._line)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Composer(Horizontal):
|
|
32
|
+
"""Prompt marker + input + model badge on one row."""
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
yield Static(RLM_AGENT_MARK, id="prompt-mark")
|
|
36
|
+
yield PromptInput()
|
|
37
|
+
yield ModelBadge()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["Composer", "ModelBadge", "RLM_AGENT_MARK"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Read-only native view of a detached factory run."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.widgets import DataTable, Footer, Static
|
|
8
|
+
|
|
9
|
+
from rlmagent.factory_graph import run_status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FactoryGraphApp(App[None]):
|
|
13
|
+
CSS = """
|
|
14
|
+
#run-state { height: auto; }
|
|
15
|
+
DataTable { height: 1fr; }
|
|
16
|
+
#stage-result { height: 8; overflow-y: auto; }
|
|
17
|
+
"""
|
|
18
|
+
BINDINGS = [("q", "quit", "Close")]
|
|
19
|
+
|
|
20
|
+
def __init__(self, directory: Path) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.directory = directory
|
|
23
|
+
self.snapshot = None
|
|
24
|
+
self.details: list[str] = []
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
yield Static("", id="run-state", markup=False)
|
|
28
|
+
yield DataTable(cursor_type="row")
|
|
29
|
+
yield Static("", id="stage-result", markup=False)
|
|
30
|
+
yield Footer()
|
|
31
|
+
|
|
32
|
+
def on_mount(self) -> None:
|
|
33
|
+
self.query_one(DataTable).add_columns("Stage", "Dependencies", "Guard", "Status")
|
|
34
|
+
self.refresh_run()
|
|
35
|
+
self.set_interval(0.5, self.refresh_run)
|
|
36
|
+
|
|
37
|
+
def refresh_run(self) -> None:
|
|
38
|
+
heading = self.query_one("#run-state", Static)
|
|
39
|
+
try:
|
|
40
|
+
state = run_status(self.directory)
|
|
41
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
42
|
+
self.snapshot = None
|
|
43
|
+
heading.update(f"Could not read the run: {exc}")
|
|
44
|
+
return
|
|
45
|
+
if state == self.snapshot:
|
|
46
|
+
return
|
|
47
|
+
self.snapshot = state
|
|
48
|
+
reason = state.get("error") or state.get("stop_reason")
|
|
49
|
+
heading.update(f"Factory {state['id']} · {state['status']}"
|
|
50
|
+
+ (f" · {reason}" if reason else ""))
|
|
51
|
+
graph, records = state.get("graph", {}), state.get("stages", {})
|
|
52
|
+
table = self.query_one(DataTable)
|
|
53
|
+
selected = table.cursor_row
|
|
54
|
+
table.clear()
|
|
55
|
+
self.details.clear()
|
|
56
|
+
for name in dict.fromkeys([*graph, *records]):
|
|
57
|
+
node = graph.get(name, {})
|
|
58
|
+
record = records.get(name, {})
|
|
59
|
+
dependencies = ", ".join(node.get("after", ()))
|
|
60
|
+
guard = node.get("when", "")
|
|
61
|
+
if source := node.get("foreach"):
|
|
62
|
+
guard += f" · foreach {source}"
|
|
63
|
+
table.add_row(Text(name), Text(dependencies), Text(guard),
|
|
64
|
+
Text(record.get("status", "waiting")))
|
|
65
|
+
self.details.append(self._output(record))
|
|
66
|
+
for index, item in enumerate(record.get("items", ()), 1):
|
|
67
|
+
table.add_row(Text(f" ↳ item {index}"), "", "", Text(item["status"]))
|
|
68
|
+
self.details.append(self._output(item))
|
|
69
|
+
if self.details:
|
|
70
|
+
selected = min(selected, len(self.details) - 1)
|
|
71
|
+
table.move_cursor(row=selected)
|
|
72
|
+
self._show_result(selected)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _output(record: dict) -> str:
|
|
76
|
+
output = record.get("output")
|
|
77
|
+
if isinstance(output, list):
|
|
78
|
+
return "\n".join(f"{index}. {item}" for index, item in enumerate(output, 1))
|
|
79
|
+
return str(output or "")
|
|
80
|
+
|
|
81
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
82
|
+
self._show_result(event.cursor_row)
|
|
83
|
+
|
|
84
|
+
def _show_result(self, row: int) -> None:
|
|
85
|
+
self.query_one("#stage-result", Static).update(
|
|
86
|
+
self.details[row] if 0 <= row < len(self.details) else "")
|
rlmagent/tui/inspect.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Full views opened from the transcript: one step in full, and the agent tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.markup import escape
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import VerticalScroll
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Static, Tree
|
|
11
|
+
|
|
12
|
+
from rlmagent.tui.messages import highlighted_code
|
|
13
|
+
from rlmagent.tui.steps import Step, StepBook, footer_text, header_text, short_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HelpScreen(ModalScreen[None]):
|
|
17
|
+
BINDINGS = [("escape", "dismiss", "Close"), ("f1", "dismiss", "Close")]
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
super().__init__(classes="inspect-screen")
|
|
21
|
+
|
|
22
|
+
def compose(self) -> ComposeResult:
|
|
23
|
+
with VerticalScroll(classes="inspect-body"):
|
|
24
|
+
yield Static("Keyboard shortcuts", classes="step-head")
|
|
25
|
+
for binding in self.app.BINDINGS:
|
|
26
|
+
keys = self.app._keymap.get(binding.id, binding.key)
|
|
27
|
+
if keys:
|
|
28
|
+
yield Static(f"{keys} {binding.description}", markup=False)
|
|
29
|
+
for line in (
|
|
30
|
+
"Esc Close this panel",
|
|
31
|
+
"Enter on a step Inspect its code and output",
|
|
32
|
+
"Space on a finished step Show or hide its details",
|
|
33
|
+
"Type / at the prompt to browse commands.",
|
|
34
|
+
):
|
|
35
|
+
yield Static(line, markup=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StepScreen(ModalScreen[None]):
|
|
39
|
+
"""Everything one step has: code, the whole output, usage and time."""
|
|
40
|
+
|
|
41
|
+
BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")]
|
|
42
|
+
|
|
43
|
+
def __init__(self, step: Step) -> None:
|
|
44
|
+
super().__init__(classes="inspect-screen")
|
|
45
|
+
self.step = step
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
step = self.step
|
|
49
|
+
with VerticalScroll(classes="inspect-body"):
|
|
50
|
+
yield Static(Text(header_text(step)), classes="step-head")
|
|
51
|
+
yield Static(Text(f"agent {step.agent} · model {step.model or 'unknown'}"),
|
|
52
|
+
classes="step-foot")
|
|
53
|
+
yield Static(highlighted_code(step.code) if step.code else "(no code)",
|
|
54
|
+
classes="step-code")
|
|
55
|
+
yield Static(escape(step.output) if step.output else "(no output)",
|
|
56
|
+
classes="step-output")
|
|
57
|
+
yield Static(Text(footer_text(step)), classes="step-foot")
|
|
58
|
+
yield Static("Esc to close", classes="step-foot")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def agent_label(book: StepBook, run_id: str) -> str:
|
|
62
|
+
agent = book.agents[run_id]
|
|
63
|
+
steps = [book.steps[(run_id, n)] for n in agent.steps]
|
|
64
|
+
tokens = sum(s.tokens for s in steps)
|
|
65
|
+
costs = [s.cost for s in steps if s.cost is not None]
|
|
66
|
+
spent = f" · ${sum(costs):.4f}" if costs else ""
|
|
67
|
+
name = book.child_names.get(agent.child_id) or short_id(agent.child_id or run_id)
|
|
68
|
+
note = book.child_notes.get(agent.child_id)
|
|
69
|
+
progress = f" · {note}" if note else ""
|
|
70
|
+
return (f"{name} · d{agent.depth} · {agent.status} · "
|
|
71
|
+
f"{len(steps)} steps · {tokens} tokens{spent}{progress}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AgentTreeScreen(ModalScreen[None]):
|
|
75
|
+
"""Who started whom, with each agent's state and spend."""
|
|
76
|
+
|
|
77
|
+
BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")]
|
|
78
|
+
|
|
79
|
+
def __init__(self, book: StepBook) -> None:
|
|
80
|
+
super().__init__(classes="inspect-screen")
|
|
81
|
+
self.book = book
|
|
82
|
+
self._agent_tree: Tree[str] = Tree("agents", classes="inspect-body")
|
|
83
|
+
self._agent_tree.show_root = False
|
|
84
|
+
self._agent_nodes = {}
|
|
85
|
+
self._empty_leaf = None
|
|
86
|
+
|
|
87
|
+
def compose(self) -> ComposeResult:
|
|
88
|
+
yield self._agent_tree
|
|
89
|
+
yield Static("Esc to close", classes="step-foot")
|
|
90
|
+
|
|
91
|
+
def on_mount(self) -> None:
|
|
92
|
+
self._agent_tree.root.expand()
|
|
93
|
+
self._refresh_tree()
|
|
94
|
+
self.set_interval(0.25, self._refresh_tree)
|
|
95
|
+
|
|
96
|
+
def _refresh_tree(self) -> None:
|
|
97
|
+
agents = self.book.agents
|
|
98
|
+
root = self._agent_tree.root
|
|
99
|
+
if self._agent_nodes.keys() - agents.keys():
|
|
100
|
+
root.remove_children()
|
|
101
|
+
self._agent_nodes.clear()
|
|
102
|
+
if not agents and self._empty_leaf is None:
|
|
103
|
+
self._empty_leaf = root.add_leaf("no agents have run yet")
|
|
104
|
+
elif agents and self._empty_leaf is not None:
|
|
105
|
+
self._empty_leaf.remove()
|
|
106
|
+
self._empty_leaf = None
|
|
107
|
+
for agent in sorted(agents.values(), key=lambda item: item.depth):
|
|
108
|
+
label = agent_label(self.book, agent.run_id)
|
|
109
|
+
node = self._agent_nodes.get(agent.run_id)
|
|
110
|
+
if node is None:
|
|
111
|
+
parent = self._agent_nodes.get(agent.parent, root)
|
|
112
|
+
self._agent_nodes[agent.run_id] = parent.add(Text(label), data=agent.run_id, expand=True)
|
|
113
|
+
elif node.label.plain != label:
|
|
114
|
+
node.set_label(Text(label))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
BAR_WIDTH = 30
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def timeline_rows(book: StepBook) -> list[str]:
|
|
121
|
+
"""One line per step in start order: offset, duration, a bar to scale, and who ran it."""
|
|
122
|
+
steps = [s for s in book.steps.values() if s.started is not None]
|
|
123
|
+
if not steps:
|
|
124
|
+
return ["No timed steps yet."]
|
|
125
|
+
steps.sort(key=lambda s: s.started)
|
|
126
|
+
first = steps[0].started
|
|
127
|
+
last = max((s.ended or s.started) for s in steps)
|
|
128
|
+
span = max(last - first, 1e-6)
|
|
129
|
+
rows = []
|
|
130
|
+
for step in steps:
|
|
131
|
+
start = int((step.started - first) / span * BAR_WIDTH)
|
|
132
|
+
length = 1 if step.ended is None else max(1, round((step.ended - step.started) / span * BAR_WIDTH))
|
|
133
|
+
bar = " " * start + ("█" if step.ended is not None else "▒") * length
|
|
134
|
+
took = "running" if step.seconds is None else f"{step.seconds:6.1f}s"
|
|
135
|
+
rows.append(f"+{step.started - first:7.1f}s {took:>8} |{bar:<{BAR_WIDTH + 1}}| "
|
|
136
|
+
f"d{step.depth} {step.short} step {step.number} {step.state}")
|
|
137
|
+
return rows
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TimelineScreen(ModalScreen[None]):
|
|
141
|
+
"""When each step ran and for how long, across every agent."""
|
|
142
|
+
|
|
143
|
+
BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")]
|
|
144
|
+
|
|
145
|
+
def __init__(self, book: StepBook) -> None:
|
|
146
|
+
super().__init__(classes="inspect-screen")
|
|
147
|
+
self.book = book
|
|
148
|
+
|
|
149
|
+
def compose(self) -> ComposeResult:
|
|
150
|
+
with VerticalScroll(classes="inspect-body"):
|
|
151
|
+
yield Static(Text("\n".join(timeline_rows(self.book))))
|
|
152
|
+
yield Static("Esc to close", classes="step-foot")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def worker_rows() -> list[str]:
|
|
156
|
+
"""Every worker: whether it runs, its last heartbeat, and its waiting and scheduled jobs."""
|
|
157
|
+
import time
|
|
158
|
+
|
|
159
|
+
from rlmagent import worker
|
|
160
|
+
|
|
161
|
+
base = worker.root()
|
|
162
|
+
names = sorted(p.name for p in base.iterdir() if p.is_dir()) if base.is_dir() else []
|
|
163
|
+
if not names:
|
|
164
|
+
return ["No workers. Start one with: rlm-agent worker start NAME"]
|
|
165
|
+
rows = []
|
|
166
|
+
for name in names:
|
|
167
|
+
meta = worker.state_of(name)
|
|
168
|
+
state = meta.get("state", "?") if meta["alive"] else "not running"
|
|
169
|
+
beat = meta.get("beat")
|
|
170
|
+
ago = f"beat {time.time() - beat:.0f}s ago" if beat else "no heartbeat"
|
|
171
|
+
jobs = worker.jobs_of(name)
|
|
172
|
+
waiting = [j for j in jobs if j.get("status") == "waiting"]
|
|
173
|
+
rows.append(f"{name}: {state}, {ago}, {len(waiting)} waiting")
|
|
174
|
+
for job in waiting[:5]:
|
|
175
|
+
when = time.strftime("%m-%d %H:%M", time.localtime(job["due"]))
|
|
176
|
+
again = f", every {job['every']:.0f}s" if job.get("every") else ""
|
|
177
|
+
rows.append(f" {job['id']} at {when}{again}: {job['text'][:80]}")
|
|
178
|
+
return rows
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class WorkersScreen(ModalScreen[None]):
|
|
182
|
+
"""Background workers and their jobs."""
|
|
183
|
+
|
|
184
|
+
BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")]
|
|
185
|
+
|
|
186
|
+
def compose(self) -> ComposeResult:
|
|
187
|
+
with VerticalScroll(classes="inspect-body"):
|
|
188
|
+
yield Static(Text("\n".join(worker_rows())))
|
|
189
|
+
yield Static("Esc to close", classes="step-foot")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
__all__ = ["AgentTreeScreen", "StepScreen", "TimelineScreen", "WorkersScreen", "agent_label",
|
|
193
|
+
"timeline_rows", "worker_rows"]
|