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/cli/main.py
ADDED
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
"""CLI entry point for rlm-agent: argument parsing, mode dispatch, and lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import importlib.metadata
|
|
8
|
+
import importlib.util
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import AsyncIterator, Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, NoReturn, TextIO
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from rlmagent.conversation import CodingSession
|
|
18
|
+
|
|
19
|
+
from rlmagent.safety import (
|
|
20
|
+
ApprovalContext,
|
|
21
|
+
ApprovalDecision,
|
|
22
|
+
ApprovalManager,
|
|
23
|
+
ApprovalPolicy,
|
|
24
|
+
clean_tool_result,
|
|
25
|
+
mark_untrusted,
|
|
26
|
+
)
|
|
27
|
+
from rlmagent.ui.render import OutputMode, make_renderer
|
|
28
|
+
from rlmagent.harness.contracts.stream import AgentEvent
|
|
29
|
+
from rlmagent.harness.contracts.tooling import ToolOutcome, ToolSpec
|
|
30
|
+
from rlmagent.harness.contracts.transcript import CallBlock, surface_text
|
|
31
|
+
from rlmagent.harness.driver import RuntimeHarness
|
|
32
|
+
from rlmagent.harness.provider.base import ModelProvider
|
|
33
|
+
from rlmagent.harness.session.index import SessionCatalog
|
|
34
|
+
from rlmagent.harness.session.records import (
|
|
35
|
+
SessionMetaRecord,
|
|
36
|
+
TagRecord,
|
|
37
|
+
TranscriptRecord,
|
|
38
|
+
)
|
|
39
|
+
from rlmagent.harness.session.store import JsonlVault
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Constants
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_PACKAGE = "rlmness-cli"
|
|
46
|
+
_DEFAULT_ANTHROPIC_MODEL = "claude-opus-5"
|
|
47
|
+
_DEFAULT_OPENAI_MODEL = "gpt-5"
|
|
48
|
+
_DEFAULT_SYSTEM = "You are a helpful coding assistant."
|
|
49
|
+
_SUBCOMMANDS = frozenset({"acp", "config", "doctor", "factory", "package", "provider", "session", "status", "tui",
|
|
50
|
+
"update", "worker"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Version
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_version() -> str:
|
|
59
|
+
"""Resolve the installed package version, falling back to ``dev``."""
|
|
60
|
+
try:
|
|
61
|
+
return importlib.metadata.version(_PACKAGE)
|
|
62
|
+
except importlib.metadata.PackageNotFoundError:
|
|
63
|
+
return "dev"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# I/O helpers
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _info(msg: str) -> None:
|
|
72
|
+
sys.stderr.write(msg + "\n")
|
|
73
|
+
sys.stderr.flush()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _warn(msg: str) -> None:
|
|
77
|
+
sys.stderr.write(f"warning: {msg}\n")
|
|
78
|
+
sys.stderr.flush()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _die(msg: str) -> NoReturn:
|
|
82
|
+
sys.stderr.write(f"error: {msg}\n")
|
|
83
|
+
sys.stderr.flush()
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Paths
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sessions_dir(override: str | None = None) -> Path:
|
|
93
|
+
"""Resolve the session directory from flag, env, or default."""
|
|
94
|
+
if override:
|
|
95
|
+
return Path(override)
|
|
96
|
+
from rlmagent.discovery import default_paths
|
|
97
|
+
|
|
98
|
+
return default_paths().sessions
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _session_path(base: Path, session_id: str) -> Path:
|
|
102
|
+
return base / f"{session_id}.jsonl"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Argument parsing
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_run_parser() -> argparse.ArgumentParser:
|
|
111
|
+
"""Build the argument parser for the default run mode."""
|
|
112
|
+
p = argparse.ArgumentParser(
|
|
113
|
+
prog="rlm-agent",
|
|
114
|
+
description="rlm-agent - a coding agent with a persistent Python kernel and sub-agents.",
|
|
115
|
+
)
|
|
116
|
+
p.add_argument(
|
|
117
|
+
"--version", "-V", action="version", version=f"rlm-agent {get_version()}",
|
|
118
|
+
)
|
|
119
|
+
p.add_argument(
|
|
120
|
+
"prompt", nargs="?", default=None,
|
|
121
|
+
help="One-shot prompt (omit for interactive mode).",
|
|
122
|
+
)
|
|
123
|
+
p.add_argument(
|
|
124
|
+
"--print", "-p", dest="print_mode", action="store_true",
|
|
125
|
+
help="Non-interactive: stream final text only, then exit.",
|
|
126
|
+
)
|
|
127
|
+
p.add_argument("--model", "-m", default=None, help="Override model identifier.")
|
|
128
|
+
p.add_argument("--provider", default=None, help="Override provider backend.")
|
|
129
|
+
p.add_argument("--offline", action="store_true", help="Skip optional network discovery and update checks.")
|
|
130
|
+
p.add_argument(
|
|
131
|
+
"--resume", "-r", metavar="ID", default=None,
|
|
132
|
+
help="Resume a previous session by its ID.",
|
|
133
|
+
)
|
|
134
|
+
p.add_argument(
|
|
135
|
+
"--allow-private-fetch", action="store_true",
|
|
136
|
+
help="Let fetch_url reach local and private network addresses.",
|
|
137
|
+
)
|
|
138
|
+
p.add_argument(
|
|
139
|
+
"--resume-anywhere", action="store_true",
|
|
140
|
+
help="Allow --resume in a folder other than the one the session started in.",
|
|
141
|
+
)
|
|
142
|
+
p.add_argument("--session-dir", default=None, help="Session storage directory.")
|
|
143
|
+
p.add_argument("--system-prompt", default=None, help="Override system prompt.")
|
|
144
|
+
p.add_argument(
|
|
145
|
+
"--max-turns", type=int, default=None,
|
|
146
|
+
help="Limit agent turns per submission.",
|
|
147
|
+
)
|
|
148
|
+
p.add_argument("--verbose", "-v", action="store_true", help="Verbose diagnostics.")
|
|
149
|
+
p.add_argument("--no-auto-improve", dest="auto_improve", action="store_false",
|
|
150
|
+
help="Do not update the playbook automatically.")
|
|
151
|
+
p.add_argument("--tools-at", default=None, metavar="SPEC",
|
|
152
|
+
help="Run the workspace tools in a tool host instead of here: "
|
|
153
|
+
"tcp://HOST:PORT (token in RLM_AGENT_TOOLS_TOKEN) or a command "
|
|
154
|
+
"that serves over stdio, e.g. 'python -m rlmagent.toolhost --root /w'.")
|
|
155
|
+
p.add_argument("--deny-git", action="store_true",
|
|
156
|
+
help="Refuse git commands that touch other branches, remotes or history "
|
|
157
|
+
"(push, fetch, checkout, branch, tag, reset, ...).")
|
|
158
|
+
p.add_argument("--shell-access", choices=("workspace", "host"), default="workspace",
|
|
159
|
+
help="Shell filesystem access: isolated workspace (default), or grant host access.")
|
|
160
|
+
p.add_argument("--shell-network", action="store_true",
|
|
161
|
+
help="Allow network access from workspace-isolated shell commands.")
|
|
162
|
+
p.add_argument("--max-tool-output", type=int, default=None, metavar="BYTES",
|
|
163
|
+
help="Most output one cell shows the model (default 20000; 0 disables this cap).")
|
|
164
|
+
p.add_argument("--max-compactions", type=int, default=None, metavar="N",
|
|
165
|
+
help="Most context compactions in this session.")
|
|
166
|
+
p.add_argument("--agent-max-tokens", type=int, default=None, metavar="N",
|
|
167
|
+
help="Most completion tokens any one agent may spend.")
|
|
168
|
+
p.add_argument("--search-block", action="append", default=None, metavar="DOMAIN",
|
|
169
|
+
help="Drop web search results from this domain, *.domain or origin "
|
|
170
|
+
"(repeatable).")
|
|
171
|
+
p.add_argument("--apply-auto-improve", dest="review_auto_improve", action="store_false",
|
|
172
|
+
help="Save automatic playbook proposals at once instead of holding them "
|
|
173
|
+
"for /improve accept.")
|
|
174
|
+
p.add_argument("--improve-every", type=int, default=5,
|
|
175
|
+
help="Check whether to update the playbook every N finished tasks (default 5).")
|
|
176
|
+
p.add_argument("--improve-cooldown", type=float, default=900.0,
|
|
177
|
+
help="Seconds between automatic playbook checks (default 900).")
|
|
178
|
+
p.add_argument("--kernel", choices=("wasm", "local"), default="wasm",
|
|
179
|
+
help="Where the python tool runs: a WebAssembly sandbox (default) or a "
|
|
180
|
+
"local IPython kernel.")
|
|
181
|
+
p.add_argument("--engine", choices=("session", "rlmness"), default="rlmness",
|
|
182
|
+
help="Execution backend (default rlmness; session reads the previous engine).")
|
|
183
|
+
p.add_argument("--restore-kernel", action="store_true",
|
|
184
|
+
help="Recover the last sandbox checkpoint after a restart (WASM only).")
|
|
185
|
+
p.add_argument("--no-network", dest="network", action="store_false",
|
|
186
|
+
help="Keep the sandbox offline: no internet, no package installs.")
|
|
187
|
+
p.add_argument("--auto-approve", action="store_true",
|
|
188
|
+
help="Let the sandbox write files and run commands without asking. "
|
|
189
|
+
"Only for scratch folders.")
|
|
190
|
+
p.add_argument(
|
|
191
|
+
"--contract", metavar="FILE", default=None,
|
|
192
|
+
help="Run with exactly the settings in this run contract (JSON); needs -p.",
|
|
193
|
+
)
|
|
194
|
+
budget = p.add_argument_group("limits for the whole tree of agents")
|
|
195
|
+
budget.add_argument("--max-depth", type=int, default=2,
|
|
196
|
+
help="How many levels of sub-agents may be started (default 2).")
|
|
197
|
+
budget.add_argument("--max-calls", type=int, default=None,
|
|
198
|
+
help="Model calls allowed across all agents (default 200; 50 for acp: models).")
|
|
199
|
+
budget.add_argument("--max-cost", type=float, default=2.0,
|
|
200
|
+
help="Spend allowed across all agents, in dollars (default 2.0).")
|
|
201
|
+
budget.add_argument("--max-agents", type=int, default=50,
|
|
202
|
+
help="Sub-agents that may be started in one session (default 50).")
|
|
203
|
+
budget.add_argument("--max-live", type=int, default=8,
|
|
204
|
+
help="Sub-agents running at the same time (default 8).")
|
|
205
|
+
budget.add_argument("--max-seconds", type=float, default=None,
|
|
206
|
+
help="Wall-clock limit for the whole run (default none).")
|
|
207
|
+
budget.add_argument("--cell-timeout", type=float, default=300.0,
|
|
208
|
+
help="Seconds one kernel cell may run before it is interrupted (default 300).")
|
|
209
|
+
budget.add_argument("--request-timeout", type=float, default=300.0,
|
|
210
|
+
help="Deadline in seconds for each complete model response (default 300).")
|
|
211
|
+
p.add_argument(
|
|
212
|
+
"--no-session", action="store_true", help="Disable session persistence.",
|
|
213
|
+
)
|
|
214
|
+
p.add_argument("--idle-passivate", type=float, default=0, metavar="SECONDS",
|
|
215
|
+
help="Let a saved worker exit after this much idle time (default disabled).")
|
|
216
|
+
p.add_argument(
|
|
217
|
+
"--repl", action="store_true",
|
|
218
|
+
help="Use the line-based REPL instead of the Textual UI.",
|
|
219
|
+
)
|
|
220
|
+
ownership = p.add_mutually_exclusive_group()
|
|
221
|
+
ownership.add_argument(
|
|
222
|
+
"--supervised", action="store_true",
|
|
223
|
+
help="Run the session in a background worker that keeps going when this window "
|
|
224
|
+
"closes, and attach to it.",
|
|
225
|
+
)
|
|
226
|
+
ownership.add_argument(
|
|
227
|
+
"--foreground", action="store_true",
|
|
228
|
+
help="Run the interactive session in this process (the default).",
|
|
229
|
+
)
|
|
230
|
+
trust = p.add_mutually_exclusive_group()
|
|
231
|
+
trust.add_argument(
|
|
232
|
+
"--approve", "-a", action="store_true",
|
|
233
|
+
help="Load this folder's project instructions, skills and plugins for this run.",
|
|
234
|
+
)
|
|
235
|
+
trust.add_argument(
|
|
236
|
+
"--no-approve", "-na", dest="no_approve", action="store_true",
|
|
237
|
+
help="Skip this folder's project inputs for this run.",
|
|
238
|
+
)
|
|
239
|
+
return p
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _build_provider_parser() -> argparse.ArgumentParser:
|
|
243
|
+
p = argparse.ArgumentParser(prog="rlm-agent provider")
|
|
244
|
+
sub = p.add_subparsers(dest="action", required=True)
|
|
245
|
+
sub.add_parser("list", help="List configured providers.")
|
|
246
|
+
sub.add_parser("setup", help="Interactive provider setup.")
|
|
247
|
+
for action in ("login", "logout"):
|
|
248
|
+
auth = sub.add_parser(action, help=f"{action.title()} a subscription account.")
|
|
249
|
+
auth.add_argument("name", choices=["codex", "copilot", "claude-subscription", "xai-subscription"])
|
|
250
|
+
if action == "login":
|
|
251
|
+
auth.add_argument("--new-account", action="store_true")
|
|
252
|
+
auth.add_argument("--client-registration", type=Path, default=None)
|
|
253
|
+
for action in ("models", "test"):
|
|
254
|
+
remote = sub.add_parser(action, help="Query the provider model endpoint.")
|
|
255
|
+
remote.add_argument("name")
|
|
256
|
+
sel = sub.add_parser("select", help="Set the active provider.")
|
|
257
|
+
sel.add_argument("name", help="Provider name to activate.")
|
|
258
|
+
return p
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _build_session_parser() -> argparse.ArgumentParser:
|
|
262
|
+
p = argparse.ArgumentParser(prog="rlm-agent session")
|
|
263
|
+
p.add_argument("--session-dir", default=None)
|
|
264
|
+
sub = p.add_subparsers(dest="action", required=True)
|
|
265
|
+
ls = sub.add_parser("list", help="List saved sessions.")
|
|
266
|
+
ls.add_argument("--session-dir", default=None, dest="sub_session_dir")
|
|
267
|
+
exp = sub.add_parser("export", help="Export a session to stdout.")
|
|
268
|
+
exp.add_argument("session_id", help="Session ID to export.")
|
|
269
|
+
exp.add_argument(
|
|
270
|
+
"--format", "-f", choices=["jsonl", "json", "text", "html"], default="jsonl",
|
|
271
|
+
)
|
|
272
|
+
exp.add_argument("--session-dir", default=None, dest="sub_session_dir")
|
|
273
|
+
share = sub.add_parser("share", help="Preview or explicitly publish an unlisted session gist.")
|
|
274
|
+
share.add_argument("session_id")
|
|
275
|
+
share.add_argument("--publish", action="store_true")
|
|
276
|
+
share.add_argument("--sha256", default=None, help="Digest of the exact previously reviewed payload")
|
|
277
|
+
share.add_argument("--session-dir", default=None, dest="sub_session_dir")
|
|
278
|
+
imp = sub.add_parser("import", help="Import a validated JSONL session under a new ID.")
|
|
279
|
+
imp.add_argument("file", type=Path)
|
|
280
|
+
imp.add_argument("--session-dir", default=None, dest="sub_session_dir")
|
|
281
|
+
st = sub.add_parser("stats", help="Print a session's token and cost totals as JSON.")
|
|
282
|
+
st.add_argument(
|
|
283
|
+
"session_id", nargs="?", default=None,
|
|
284
|
+
help="Session ID to summarize (default: the most recent session).",
|
|
285
|
+
)
|
|
286
|
+
st.add_argument("--session-dir", default=None, dest="sub_session_dir")
|
|
287
|
+
vw = sub.add_parser("view", help="Replay a session's agent steps as cards.")
|
|
288
|
+
vw.add_argument("session_id", nargs="?", default=None,
|
|
289
|
+
help="Session ID (default: the most recent session).")
|
|
290
|
+
vw.add_argument("--session-dir", default=None, dest="sub_session_dir")
|
|
291
|
+
sp = sub.add_parser("steps", help="Per-agent steps, tokens, cost and time of a session.")
|
|
292
|
+
sp.add_argument("session_id", nargs="?", default=None,
|
|
293
|
+
help="Session ID (default: the most recent session).")
|
|
294
|
+
sp.add_argument("--json", action="store_true", help="Print one JSON object per agent.")
|
|
295
|
+
sp.add_argument("--session-dir", default=None, dest="sub_session_dir")
|
|
296
|
+
pr = sub.add_parser("prune", help="List old sessions; remove them with --yes.")
|
|
297
|
+
pr.add_argument("--older-than", type=int, required=True, metavar="DAYS")
|
|
298
|
+
pr.add_argument("--yes", action="store_true", help="Actually remove them.")
|
|
299
|
+
pr.add_argument("--session-dir", default=None, dest="sub_session_dir")
|
|
300
|
+
return p
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Provider / model resolution
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _resolve_provider(name: str | None) -> ModelProvider:
|
|
309
|
+
"""Build a ``ModelProvider`` from the given name, env, or config module."""
|
|
310
|
+
from rlmagent.config.config import resolve_provider
|
|
311
|
+
from rlmagent.model.settings import ConfigError
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
return resolve_provider(name)
|
|
315
|
+
except ConfigError as exc:
|
|
316
|
+
_die(str(exc))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _resolve_provider_name(explicit: str | None) -> str:
|
|
320
|
+
"""Provider name from the flag, then ``RLM_AGENT_PROVIDER``, then saved config."""
|
|
321
|
+
if explicit:
|
|
322
|
+
return explicit.strip().lower()
|
|
323
|
+
from rlmagent.config.loader import load_provider
|
|
324
|
+
return load_provider()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _resolve_model(override: str | None, provider_name: str = "anthropic") -> str:
|
|
328
|
+
"""Model from the ``--model`` flag, ``RLM_AGENT_MODEL``, or the provider's default."""
|
|
329
|
+
explicit = override or os.environ.get("RLM_AGENT_MODEL")
|
|
330
|
+
if explicit:
|
|
331
|
+
return explicit
|
|
332
|
+
if provider_name == "openai":
|
|
333
|
+
return _DEFAULT_OPENAI_MODEL
|
|
334
|
+
if provider_name == "cli":
|
|
335
|
+
return "cli:claude-code"
|
|
336
|
+
if provider_name == "vertex":
|
|
337
|
+
return "google/gemini-3.5-flash"
|
|
338
|
+
if provider_name in ("google", "mistral", "azure", "codex", "copilot", "claude-subscription", "xai", "xai-subscription"):
|
|
339
|
+
from rlmagent.config.store import provider_info
|
|
340
|
+
|
|
341
|
+
return provider_info(provider_name).models[0]
|
|
342
|
+
return _DEFAULT_ANTHROPIC_MODEL
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _resolve_system(
|
|
346
|
+
override: str | None,
|
|
347
|
+
*,
|
|
348
|
+
tools: Sequence[ToolSpec] = (),
|
|
349
|
+
skills: Sequence[object] = (),
|
|
350
|
+
) -> str:
|
|
351
|
+
if override:
|
|
352
|
+
return override
|
|
353
|
+
try:
|
|
354
|
+
from rlmagent.instructions import system_prompt
|
|
355
|
+
return system_prompt(tools=tools, skills=skills) # type: ignore[arg-type]
|
|
356
|
+
except (ImportError, AttributeError):
|
|
357
|
+
return _DEFAULT_SYSTEM
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# Tool loading
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _load_tools(verbose: bool = False, kernel=None) -> list[ToolSpec]:
|
|
366
|
+
try:
|
|
367
|
+
from rlmagent.tools import build_tool_registry # type: ignore[import-not-found]
|
|
368
|
+
tools: list[ToolSpec] = build_tool_registry(kernel)
|
|
369
|
+
if verbose:
|
|
370
|
+
_info(f"Loaded {len(tools)} tool(s).")
|
|
371
|
+
return tools
|
|
372
|
+
except (ImportError, AttributeError):
|
|
373
|
+
if verbose:
|
|
374
|
+
_info("Tool module not available; running without tools.")
|
|
375
|
+
return []
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Skill loading
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _load_skills(cwd: str, verbose: bool = False) -> list[object]:
|
|
384
|
+
"""Load skills from all resource directories with full precedence."""
|
|
385
|
+
try:
|
|
386
|
+
from rlmagent.discovery import default_paths, skill_search_paths
|
|
387
|
+
from rlmagent.skillset import load_skills
|
|
388
|
+
|
|
389
|
+
paths = default_paths(project=Path(cwd))
|
|
390
|
+
dirs = skill_search_paths(paths)
|
|
391
|
+
skills = load_skills(dirs)
|
|
392
|
+
if verbose:
|
|
393
|
+
searched = ", ".join(str(d) for d in dirs)
|
|
394
|
+
_info(f"Loaded {len(skills)} skill(s) (searched: {searched}).")
|
|
395
|
+
return list(skills)
|
|
396
|
+
except (ImportError, AttributeError):
|
|
397
|
+
if verbose:
|
|
398
|
+
_info("Skill module not available; running without skills.")
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _load_prompt_templates(cwd: str, verbose: bool = False) -> list[object]:
|
|
403
|
+
"""Load markdown prompt templates from all resource directories."""
|
|
404
|
+
try:
|
|
405
|
+
from rlmagent.discovery import default_paths, prompt_search_paths
|
|
406
|
+
from rlmagent.prompts import load_prompt_templates
|
|
407
|
+
|
|
408
|
+
paths = default_paths(project=Path(cwd))
|
|
409
|
+
dirs = prompt_search_paths(paths)
|
|
410
|
+
templates = load_prompt_templates(dirs)
|
|
411
|
+
if verbose:
|
|
412
|
+
searched = ", ".join(str(d) for d in dirs)
|
|
413
|
+
_info(f"Loaded {len(templates)} prompt template(s) (searched: {searched}).")
|
|
414
|
+
return list(templates)
|
|
415
|
+
except (ImportError, AttributeError):
|
|
416
|
+
if verbose:
|
|
417
|
+
_info("Prompt template module not available; running without templates.")
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
# Hook wiring
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _install_hooks(harness: RuntimeHarness, verbose: bool = False) -> None:
|
|
427
|
+
try:
|
|
428
|
+
from rlmagent.hooks.hooks import install_hooks # type: ignore[import-not-found]
|
|
429
|
+
install_hooks(harness)
|
|
430
|
+
if verbose:
|
|
431
|
+
_info("Hooks installed.")
|
|
432
|
+
except (ImportError, AttributeError):
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
_MUTATING_TOOLS = frozenset({"Write", "Edit", "python"})
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _approval_context(call: CallBlock) -> ApprovalContext:
|
|
440
|
+
"""Describe a pending tool call for the approval manager."""
|
|
441
|
+
args = call.arguments
|
|
442
|
+
command = args.get("command", args.get("code"))
|
|
443
|
+
path = args.get("file_path")
|
|
444
|
+
return ApprovalContext(
|
|
445
|
+
tool_name=call.name,
|
|
446
|
+
is_mutating=call.name in _MUTATING_TOOLS,
|
|
447
|
+
affected_paths=(str(path),) if isinstance(path, str) else (),
|
|
448
|
+
command=command if isinstance(command, str) else None,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _install_safety(harness: RuntimeHarness, policy: ApprovalPolicy) -> None:
|
|
453
|
+
"""Gate tool calls through the approval manager and sanitize their results."""
|
|
454
|
+
manager = ApprovalManager(policy)
|
|
455
|
+
|
|
456
|
+
async def before_tool_call(call: CallBlock) -> tuple[bool, str | None]:
|
|
457
|
+
decision = await manager.check(_approval_context(call))
|
|
458
|
+
if decision is ApprovalDecision.APPROVED:
|
|
459
|
+
return False, None
|
|
460
|
+
return True, f"Blocked by safety policy ({decision.value}): {call.name}"
|
|
461
|
+
|
|
462
|
+
async def after_tool_call(
|
|
463
|
+
call: CallBlock, result: ToolOutcome, is_error: bool,
|
|
464
|
+
) -> tuple[ToolOutcome, bool]:
|
|
465
|
+
return mark_untrusted(clean_tool_result(result)), is_error
|
|
466
|
+
|
|
467
|
+
harness.settings.before_tool_call = before_tool_call
|
|
468
|
+
harness.settings.after_tool_call = after_tool_call
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# Extension loading
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _load_extensions(verbose: bool = False) -> list[object]:
|
|
477
|
+
"""Discover and activate extensions from the extension directories."""
|
|
478
|
+
from rlmagent.plugins import load_extensions
|
|
479
|
+
|
|
480
|
+
result = load_extensions()
|
|
481
|
+
|
|
482
|
+
for failure in result.failures:
|
|
483
|
+
_warn(f"Extension {failure.name!r} failed: {failure.error}")
|
|
484
|
+
|
|
485
|
+
if verbose:
|
|
486
|
+
searched = ", ".join(str(path) for path in result.searched)
|
|
487
|
+
_info(f"Extensions: {result.count} loaded (searched: {searched})")
|
|
488
|
+
for extension in result.loaded:
|
|
489
|
+
_info(f" {extension.name} <- {extension.path}")
|
|
490
|
+
|
|
491
|
+
return [extension.value for extension in result.loaded]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# Session helpers
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
async def _list_sessions(base: Path, out: TextIO) -> None:
|
|
500
|
+
if not base.exists():
|
|
501
|
+
_info("No sessions found.")
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
# Prefer the catalog index when available
|
|
505
|
+
catalog = SessionCatalog(base)
|
|
506
|
+
entries = catalog.list_all()
|
|
507
|
+
if entries:
|
|
508
|
+
for meta in entries:
|
|
509
|
+
label = meta.title or meta.session_id
|
|
510
|
+
out.write(f" {meta.session_id} {label} ({meta.provider}/{meta.model})\n")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
# Fallback: scan vault files directly (pre-index sessions)
|
|
514
|
+
files = sorted(base.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
515
|
+
# Exclude the index file itself
|
|
516
|
+
files = [f for f in files if f.name != "index.jsonl"]
|
|
517
|
+
if not files:
|
|
518
|
+
_info("No sessions found.")
|
|
519
|
+
return
|
|
520
|
+
for path in files:
|
|
521
|
+
vault = JsonlVault(path)
|
|
522
|
+
try:
|
|
523
|
+
records = await vault.read_all()
|
|
524
|
+
except Exception: # noqa: BLE001
|
|
525
|
+
out.write(f" {path.stem} (unreadable)\n")
|
|
526
|
+
continue
|
|
527
|
+
title = path.stem
|
|
528
|
+
n_messages = 0
|
|
529
|
+
for r in records:
|
|
530
|
+
if isinstance(r, TagRecord):
|
|
531
|
+
title = r.label
|
|
532
|
+
elif isinstance(r, SessionMetaRecord) and r.title:
|
|
533
|
+
title = r.title
|
|
534
|
+
elif isinstance(r, TranscriptRecord):
|
|
535
|
+
n_messages += 1
|
|
536
|
+
out.write(f" {path.stem} {title} ({n_messages} messages)\n")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
async def _export_session(base: Path, sid: str, fmt: str, out: TextIO) -> None:
|
|
540
|
+
path = _session_path(base, sid)
|
|
541
|
+
if not path.exists():
|
|
542
|
+
_die(f"Session not found: {sid}")
|
|
543
|
+
records = await JsonlVault(path).read_all()
|
|
544
|
+
if fmt == "jsonl":
|
|
545
|
+
from rlmagent.harness.session.store import serialize_record
|
|
546
|
+
for r in records:
|
|
547
|
+
out.write(serialize_record(r))
|
|
548
|
+
elif fmt == "json":
|
|
549
|
+
from pydantic import TypeAdapter
|
|
550
|
+
|
|
551
|
+
from rlmagent.harness.session.records import SessionRecord
|
|
552
|
+
out.write(
|
|
553
|
+
TypeAdapter(list[SessionRecord]).dump_json(records, indent=2).decode() + "\n"
|
|
554
|
+
)
|
|
555
|
+
elif fmt == "html":
|
|
556
|
+
from rlmagent.session_html import render
|
|
557
|
+
|
|
558
|
+
out.write(render([], sid, records=records))
|
|
559
|
+
elif fmt == "text":
|
|
560
|
+
for r in records:
|
|
561
|
+
if isinstance(r, TranscriptRecord):
|
|
562
|
+
out.write(f"[{r.message.role}] {surface_text(r.message)}\n\n")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _latest_session_id(base: Path) -> str | None:
|
|
566
|
+
"""Session id of the most recently modified vault file, if any."""
|
|
567
|
+
if not base.exists():
|
|
568
|
+
return None
|
|
569
|
+
# Run event logs sit beside the vaults as <id>.backend.events.jsonl; skip them.
|
|
570
|
+
files = [p for p in base.glob("*.jsonl") if p.name != "index.jsonl" and "." not in p.stem]
|
|
571
|
+
if not files:
|
|
572
|
+
return None
|
|
573
|
+
return max(files, key=lambda p: p.stat().st_mtime).stem
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
async def _session_stats(base: Path, sid: str | None, out: TextIO) -> None:
|
|
577
|
+
"""Write one JSON object of token and cost totals for a session."""
|
|
578
|
+
import json
|
|
579
|
+
from dataclasses import asdict
|
|
580
|
+
|
|
581
|
+
from rlmagent.harness.session.summary import summarize_records
|
|
582
|
+
|
|
583
|
+
resolved = sid or _latest_session_id(base)
|
|
584
|
+
if resolved is None:
|
|
585
|
+
_die("No sessions found.")
|
|
586
|
+
path = _session_path(base, resolved)
|
|
587
|
+
if not path.exists():
|
|
588
|
+
_die(f"Session not found: {resolved}")
|
|
589
|
+
records = await JsonlVault(path).read_all()
|
|
590
|
+
out.write(json.dumps(asdict(summarize_records(resolved, records))) + "\n")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# ---------------------------------------------------------------------------
|
|
594
|
+
# Provider subcommand
|
|
595
|
+
# ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _handle_provider(argv: list[str]) -> int:
|
|
599
|
+
ns = _build_provider_parser().parse_args(argv)
|
|
600
|
+
|
|
601
|
+
if ns.action in ("login", "logout"):
|
|
602
|
+
from rlmagent.model.settings import ConfigError
|
|
603
|
+
from rlmagent.model.subscription import login, logout
|
|
604
|
+
|
|
605
|
+
if ns.name == "xai-subscription":
|
|
606
|
+
from rlmagent.model import registered_oauth
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
if ns.action == "login":
|
|
610
|
+
asyncio.run(registered_oauth.login(ns.client_registration))
|
|
611
|
+
_info("Registered OAuth token saved. Verify inference access separately.")
|
|
612
|
+
else:
|
|
613
|
+
asyncio.run(registered_oauth.logout())
|
|
614
|
+
_info("Local OAuth tokens cleared; provider-side revocation was not attempted.")
|
|
615
|
+
return 0
|
|
616
|
+
except (ConfigError, OSError, ValueError) as error:
|
|
617
|
+
_info(str(error))
|
|
618
|
+
return 1
|
|
619
|
+
if ns.name != "codex":
|
|
620
|
+
import shutil
|
|
621
|
+
import subprocess
|
|
622
|
+
|
|
623
|
+
executable = shutil.which("copilot" if ns.name == "copilot" else "claude")
|
|
624
|
+
if executable is None:
|
|
625
|
+
_info("Install the provider's official CLI to use its account sign-in.")
|
|
626
|
+
return 1
|
|
627
|
+
if ns.name == "copilot" and ns.action == "logout":
|
|
628
|
+
_info("Run /logout inside the official Copilot CLI to sign out.")
|
|
629
|
+
return 0
|
|
630
|
+
command = [executable, ns.action] if ns.name == "copilot" else [executable, "auth", ns.action]
|
|
631
|
+
return subprocess.run(command, check=False).returncode
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
if ns.action == "login":
|
|
635
|
+
_info("Continue with ChatGPT in your browser. Waiting for consent...")
|
|
636
|
+
asyncio.run(login(new_account=ns.new_account))
|
|
637
|
+
_info("Subscription sign-in saved in the platform credential store. "
|
|
638
|
+
"Use --provider codex to run an inference check.")
|
|
639
|
+
else:
|
|
640
|
+
confirmed = asyncio.run(logout())
|
|
641
|
+
_info("Signed out locally." + ("" if confirmed else
|
|
642
|
+
" Remote revocation was not confirmed; disconnect in ChatGPT Settings."))
|
|
643
|
+
return 0
|
|
644
|
+
except ConfigError as exc:
|
|
645
|
+
_info(str(exc))
|
|
646
|
+
return 1
|
|
647
|
+
|
|
648
|
+
if ns.action in ("models", "test"):
|
|
649
|
+
from rlmagent.model.discovery import discover_models, check_provider
|
|
650
|
+
try:
|
|
651
|
+
if ns.action == "test":
|
|
652
|
+
_info(asyncio.run(check_provider(ns.name)))
|
|
653
|
+
return 0
|
|
654
|
+
models = asyncio.run(discover_models(ns.name))
|
|
655
|
+
except Exception as error:
|
|
656
|
+
# HTTP errors can contain endpoint details; never include authentication headers.
|
|
657
|
+
_info(f"Provider check failed: {type(error).__name__}")
|
|
658
|
+
return 1
|
|
659
|
+
if ns.action == "models":
|
|
660
|
+
for model in models:
|
|
661
|
+
print(model)
|
|
662
|
+
else:
|
|
663
|
+
_info(f"Provider model endpoint accepted credentials; {len(models)} models returned")
|
|
664
|
+
return 0
|
|
665
|
+
if ns.action == "list":
|
|
666
|
+
from rlmagent.config.config import list_providers
|
|
667
|
+
list_providers()
|
|
668
|
+
return 0
|
|
669
|
+
|
|
670
|
+
if ns.action == "setup":
|
|
671
|
+
from rlmagent.config.config import setup_provider
|
|
672
|
+
setup_provider()
|
|
673
|
+
return 0
|
|
674
|
+
|
|
675
|
+
if ns.action == "select":
|
|
676
|
+
from rlmagent.config.config import select_provider
|
|
677
|
+
from rlmagent.model.settings import ConfigError
|
|
678
|
+
try:
|
|
679
|
+
select_provider(ns.name)
|
|
680
|
+
_info(f"Active provider: {ns.name}")
|
|
681
|
+
except ConfigError as exc:
|
|
682
|
+
_die(str(exc))
|
|
683
|
+
return 0
|
|
684
|
+
|
|
685
|
+
return 1
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ---------------------------------------------------------------------------
|
|
689
|
+
# Session subcommand
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _textual_available() -> bool:
|
|
694
|
+
"""Whether the optional ``textual`` extra is importable."""
|
|
695
|
+
return importlib.util.find_spec("textual") is not None
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _handle_tui(ns: argparse.Namespace | None = None) -> int:
|
|
699
|
+
"""Launch the Textual UI over the shared runtime session."""
|
|
700
|
+
if not _textual_available():
|
|
701
|
+
_die(
|
|
702
|
+
"The TUI requires the 'textual' extra.\n"
|
|
703
|
+
" Install it with: pip install rlmness-cli[tui]"
|
|
704
|
+
)
|
|
705
|
+
from rlmagent.tui.app import run_tui
|
|
706
|
+
return run_tui(ns)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _should_launch_tui(ns: argparse.Namespace) -> bool:
|
|
710
|
+
"""Whether a bare ``rlm-agent`` invocation should open the Textual UI."""
|
|
711
|
+
if ns.repl or ns.print_mode or ns.prompt is not None or ns.resume:
|
|
712
|
+
return False
|
|
713
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
714
|
+
return False
|
|
715
|
+
return _textual_available()
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _handle_session(argv: list[str]) -> int:
|
|
719
|
+
if argv[:1] in (["detach"], ["attach"]):
|
|
720
|
+
from rlmagent.worker import handle_session
|
|
721
|
+
|
|
722
|
+
return handle_session(argv)
|
|
723
|
+
ns = _build_session_parser().parse_args(argv)
|
|
724
|
+
session_dir = ns.session_dir or getattr(ns, "sub_session_dir", None)
|
|
725
|
+
base = _sessions_dir(session_dir)
|
|
726
|
+
|
|
727
|
+
if ns.action == "list":
|
|
728
|
+
asyncio.run(_list_sessions(base, sys.stdout))
|
|
729
|
+
return 0
|
|
730
|
+
|
|
731
|
+
if ns.action == "export":
|
|
732
|
+
asyncio.run(_export_session(base, ns.session_id, ns.format, sys.stdout))
|
|
733
|
+
return 0
|
|
734
|
+
if ns.action == "share":
|
|
735
|
+
import json
|
|
736
|
+
import re
|
|
737
|
+
|
|
738
|
+
from rlmagent.harness.session.store import serialize_record
|
|
739
|
+
from rlmagent.session_html import render
|
|
740
|
+
from rlmagent.session_share import prepare, publish
|
|
741
|
+
|
|
742
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", ns.session_id):
|
|
743
|
+
_die("Invalid session identifier")
|
|
744
|
+
async def share_session():
|
|
745
|
+
records = await JsonlVault(_session_path(base, ns.session_id)).read_all()
|
|
746
|
+
if not records:
|
|
747
|
+
raise ValueError("No session records found")
|
|
748
|
+
body, digest = prepare(ns.session_id, render(
|
|
749
|
+
[record.message for record in records if isinstance(record, TranscriptRecord)],
|
|
750
|
+
ns.session_id, records=records), "".join(serialize_record(record) for record in records))
|
|
751
|
+
if not ns.publish:
|
|
752
|
+
print(json.dumps(body, indent=2, ensure_ascii=False))
|
|
753
|
+
print(f"SHA-256: {digest}")
|
|
754
|
+
print("Unlisted gist: anyone with the link can read it. Nothing uploaded.")
|
|
755
|
+
return
|
|
756
|
+
print(await publish(body, expected_digest=ns.sha256,
|
|
757
|
+
token=os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")))
|
|
758
|
+
try:
|
|
759
|
+
asyncio.run(share_session())
|
|
760
|
+
except (OSError, ValueError) as error:
|
|
761
|
+
_die(str(error))
|
|
762
|
+
return 0
|
|
763
|
+
if ns.action == "import":
|
|
764
|
+
from rlmagent.session_import import import_session
|
|
765
|
+
|
|
766
|
+
try:
|
|
767
|
+
print(import_session(ns.file, base))
|
|
768
|
+
except (OSError, ValueError) as exc:
|
|
769
|
+
_die(f"Cannot import session: {exc}")
|
|
770
|
+
return 0
|
|
771
|
+
|
|
772
|
+
if ns.action == "stats":
|
|
773
|
+
asyncio.run(_session_stats(base, ns.session_id, sys.stdout))
|
|
774
|
+
return 0
|
|
775
|
+
|
|
776
|
+
if ns.action in ("view", "steps"):
|
|
777
|
+
from rlmagent.tui.replay import (
|
|
778
|
+
ReplayApp, agent_rows, events_path, format_rows, load_events, runs_of,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
resolved = ns.session_id or _latest_session_id(base)
|
|
782
|
+
if resolved is None:
|
|
783
|
+
_die("No sessions found.")
|
|
784
|
+
path = events_path(base, resolved)
|
|
785
|
+
if not path.exists():
|
|
786
|
+
_die(f"No agent steps saved for {resolved}; steps are kept for --engine rlmness.")
|
|
787
|
+
books = runs_of(load_events(path))
|
|
788
|
+
if ns.action == "view":
|
|
789
|
+
ReplayApp(resolved, books).run()
|
|
790
|
+
return 0
|
|
791
|
+
rows = agent_rows(books)
|
|
792
|
+
if ns.json:
|
|
793
|
+
import json
|
|
794
|
+
for row in rows:
|
|
795
|
+
print(json.dumps(row))
|
|
796
|
+
else:
|
|
797
|
+
print(format_rows(rows))
|
|
798
|
+
return 0
|
|
799
|
+
|
|
800
|
+
if ns.action == "prune":
|
|
801
|
+
from rlmagent.plugins import rlm_agent_home
|
|
802
|
+
from rlmagent.prune import prune
|
|
803
|
+
|
|
804
|
+
if ns.older_than < 1:
|
|
805
|
+
_die("--older-than must be at least 1 day.")
|
|
806
|
+
return prune(base, rlm_agent_home(), ns.older_than, apply=ns.yes)
|
|
807
|
+
|
|
808
|
+
return 1
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
# Event rendering
|
|
813
|
+
# ---------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
async def _consume_events(
|
|
817
|
+
events: AsyncIterator[AgentEvent],
|
|
818
|
+
*,
|
|
819
|
+
print_mode: bool,
|
|
820
|
+
) -> bool:
|
|
821
|
+
"""Consume the event stream and render output, returning overall success."""
|
|
822
|
+
mode = OutputMode.TEXT if print_mode else OutputMode.TRANSCRIPT
|
|
823
|
+
renderer = make_renderer(mode)
|
|
824
|
+
async for event in events:
|
|
825
|
+
renderer.render(event)
|
|
826
|
+
return renderer.finish()
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# ---------------------------------------------------------------------------
|
|
830
|
+
# Run modes
|
|
831
|
+
# ---------------------------------------------------------------------------
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
#: ``/help`` is rendered from the shared command registry by the session, so
|
|
835
|
+
#: this file keeps no command list of its own to drift out of date. Only the
|
|
836
|
+
#: skill-invocation form, which is not a registered command, is appended.
|
|
837
|
+
_HELP_SUFFIX = " /skill:<name> [args] Invoke a skill"
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
async def _run_one_shot(
|
|
841
|
+
session: CodingSession,
|
|
842
|
+
prompt: str,
|
|
843
|
+
*,
|
|
844
|
+
print_mode: bool,
|
|
845
|
+
) -> int:
|
|
846
|
+
"""Submit a single prompt and exit."""
|
|
847
|
+
events = session.submit(prompt)
|
|
848
|
+
ok = await _consume_events(events, print_mode=print_mode)
|
|
849
|
+
ok = await _keep_going(session, ok, print_mode=print_mode)
|
|
850
|
+
sys.stdout.flush()
|
|
851
|
+
return 0 if ok else 1
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
async def _keep_going(session: CodingSession, ok: bool, *, print_mode: bool) -> bool:
|
|
855
|
+
"""Run the turns autonomous mode starts on its own, until it stops."""
|
|
856
|
+
pilot = getattr(session, "autopilot", None)
|
|
857
|
+
while ok and pilot is not None and (following := pilot.next_prompt()):
|
|
858
|
+
_info(f"[autonomous turn {pilot.turns}]")
|
|
859
|
+
ok = await _consume_events(session.submit(following), print_mode=print_mode)
|
|
860
|
+
if pilot is not None and pilot.stopped_because:
|
|
861
|
+
_info(f"[autonomous mode stopped: {pilot.stopped_because}]")
|
|
862
|
+
pilot.stopped_because = ""
|
|
863
|
+
return ok
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
async def _run_interactive(session: CodingSession) -> int:
|
|
867
|
+
"""Run the interactive prompt loop until the user exits."""
|
|
868
|
+
loop = asyncio.get_running_loop()
|
|
869
|
+
_info("Type /help for commands, /auto to toggle auto-accept, /quit to exit.\n")
|
|
870
|
+
tree = getattr(session, "agent_tree", None)
|
|
871
|
+
if tree is not None and tree.approver.policy in ("ask", "auto"):
|
|
872
|
+
tree.approver.ask = _ask_on_console
|
|
873
|
+
if tree is not None:
|
|
874
|
+
tree.approver.question = _question_on_console
|
|
875
|
+
|
|
876
|
+
while True:
|
|
877
|
+
try:
|
|
878
|
+
line = await loop.run_in_executor(None, _prompt_user)
|
|
879
|
+
except (EOFError, KeyboardInterrupt):
|
|
880
|
+
break
|
|
881
|
+
|
|
882
|
+
if line is None:
|
|
883
|
+
break
|
|
884
|
+
text = line.strip()
|
|
885
|
+
if not text:
|
|
886
|
+
continue
|
|
887
|
+
|
|
888
|
+
# CLI-level built-ins
|
|
889
|
+
if text in {"/quit", "/exit", "/q"}:
|
|
890
|
+
break
|
|
891
|
+
if text == "/version":
|
|
892
|
+
_info(f"rlm-agent v{get_version()}")
|
|
893
|
+
continue
|
|
894
|
+
if text == "/auto" and tree is not None:
|
|
895
|
+
on = tree.approver.toggle_auto()
|
|
896
|
+
_info("auto-accept on: file changes and commands go ahead unasked" if on
|
|
897
|
+
else "auto-accept off: writes and commands ask first")
|
|
898
|
+
continue
|
|
899
|
+
|
|
900
|
+
# Resuming yields an event stream rather than a string, so it cannot go
|
|
901
|
+
# through handle_command like the other session commands.
|
|
902
|
+
if text in {"/continue", "/resume"}:
|
|
903
|
+
if session.active:
|
|
904
|
+
_warn("Agent is already running.")
|
|
905
|
+
continue
|
|
906
|
+
try:
|
|
907
|
+
events = session.resume_run()
|
|
908
|
+
except RuntimeError as exc:
|
|
909
|
+
_warn(str(exc))
|
|
910
|
+
continue
|
|
911
|
+
await _consume_events(events, print_mode=False)
|
|
912
|
+
continue
|
|
913
|
+
|
|
914
|
+
# Session-level slash commands (/model, /compact, /stats, /export, ...)
|
|
915
|
+
try:
|
|
916
|
+
response = await session.handle_command(text)
|
|
917
|
+
except Exception as exc: # noqa: BLE001 - a bad command must not kill the REPL
|
|
918
|
+
_warn(f"Command failed: {exc}")
|
|
919
|
+
continue
|
|
920
|
+
if response is not None:
|
|
921
|
+
if text == "/help":
|
|
922
|
+
response = f"{response}\n{_HELP_SUFFIX}"
|
|
923
|
+
_info(response)
|
|
924
|
+
if text.split() in (["/autonomous"], ["/autonomous", "on"]):
|
|
925
|
+
await _keep_going(session, True, print_mode=False)
|
|
926
|
+
continue
|
|
927
|
+
|
|
928
|
+
# A registered command the session does not run (terminal-UI pickers and
|
|
929
|
+
# the like) must not reach the model as a prompt.
|
|
930
|
+
from rlmagent.conversation import COMMAND_REGISTRY
|
|
931
|
+
from rlmagent.directives import parse_command
|
|
932
|
+
|
|
933
|
+
parsed = parse_command(text)
|
|
934
|
+
command = COMMAND_REGISTRY.get(parsed[0]) if parsed is not None else None
|
|
935
|
+
if command is not None:
|
|
936
|
+
_warn(f"/{command.name.lstrip('/')} is not available in the line REPL.")
|
|
937
|
+
continue
|
|
938
|
+
|
|
939
|
+
# Ordinary prompt
|
|
940
|
+
try:
|
|
941
|
+
events = session.submit(text)
|
|
942
|
+
except RuntimeError as exc:
|
|
943
|
+
_warn(str(exc))
|
|
944
|
+
continue
|
|
945
|
+
ok = await _consume_events(events, print_mode=False)
|
|
946
|
+
await _keep_going(session, ok, print_mode=False)
|
|
947
|
+
|
|
948
|
+
return 0
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
APPROVAL_KEYS = {"y": "once", "a": "always", "t": "auto", "n": "deny"}
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
async def _ask_on_console(request) -> str:
|
|
955
|
+
"""Line-mode approval: show what would change and read one key."""
|
|
956
|
+
from rlmagent.agents.approval import DENY
|
|
957
|
+
|
|
958
|
+
always = "this command" if request.kind == "run" else "this file"
|
|
959
|
+
_info(f"\nThe agent wants to:\n{request.describe()}")
|
|
960
|
+
question = f"Allow? [y] once [a] always {always} [t] and auto-accept the rest [n] deny: "
|
|
961
|
+
loop = asyncio.get_running_loop()
|
|
962
|
+
while True:
|
|
963
|
+
try:
|
|
964
|
+
answer = await loop.run_in_executor(None, input, question)
|
|
965
|
+
except (EOFError, KeyboardInterrupt):
|
|
966
|
+
return DENY
|
|
967
|
+
choice = APPROVAL_KEYS.get(answer.strip().lower()[:1])
|
|
968
|
+
if choice is not None:
|
|
969
|
+
return choice
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
async def _question_on_console(question: str, choices: list[str] | None, multiple: bool = False):
|
|
973
|
+
"""Line-mode question: numbers pick choices (commas for several); anything else is the answer."""
|
|
974
|
+
_info(f"\nThe agent asks:\n{question}")
|
|
975
|
+
for number, choice in enumerate(choices or [], start=1):
|
|
976
|
+
_info(f" {number}. {choice}")
|
|
977
|
+
try:
|
|
978
|
+
answer = await asyncio.get_running_loop().run_in_executor(None, input, "Answer: ")
|
|
979
|
+
except (EOFError, KeyboardInterrupt):
|
|
980
|
+
return None
|
|
981
|
+
answer = answer.strip()
|
|
982
|
+
picks = [p.strip() for p in answer.split(",")]
|
|
983
|
+
if multiple and choices and all(p.isdigit() and 1 <= int(p) <= len(choices) for p in picks):
|
|
984
|
+
return [choices[int(p) - 1] for p in picks]
|
|
985
|
+
if choices and answer.isdigit() and 1 <= int(answer) <= len(choices):
|
|
986
|
+
return choices[int(answer) - 1]
|
|
987
|
+
return answer or None
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _prompt_user() -> str | None:
|
|
991
|
+
"""Read a line of input from the user, returning ``None`` on EOF."""
|
|
992
|
+
try:
|
|
993
|
+
return input("\nrlm-agent> ")
|
|
994
|
+
except EOFError:
|
|
995
|
+
return None
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
# ---------------------------------------------------------------------------
|
|
999
|
+
# Signal handling
|
|
1000
|
+
# ---------------------------------------------------------------------------
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _wire_signals(session: CodingSession) -> None:
|
|
1004
|
+
"""Install a SIGINT handler: first press aborts the active run, idle press"""
|
|
1005
|
+
|
|
1006
|
+
def _handler(_signum: int, _frame: object) -> None:
|
|
1007
|
+
if session.active:
|
|
1008
|
+
session.abort()
|
|
1009
|
+
return
|
|
1010
|
+
raise KeyboardInterrupt
|
|
1011
|
+
|
|
1012
|
+
signal.signal(signal.SIGINT, _handler)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
# ---------------------------------------------------------------------------
|
|
1016
|
+
# Startup banner
|
|
1017
|
+
# ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _banner(provider: str, model: str, session_id: str | None, verbose: bool) -> None:
|
|
1021
|
+
from rlmagent.config.store import PROVIDERS
|
|
1022
|
+
|
|
1023
|
+
label = next((p.label for p in PROVIDERS if p.key == provider), provider)
|
|
1024
|
+
parts = [
|
|
1025
|
+
f"rlm-agent v{get_version()}",
|
|
1026
|
+
f"Provider: {label}",
|
|
1027
|
+
f"Model: {model}",
|
|
1028
|
+
]
|
|
1029
|
+
if session_id:
|
|
1030
|
+
parts.append(f"Session: {session_id}")
|
|
1031
|
+
if verbose:
|
|
1032
|
+
parts.append(f"Python: {sys.version.split()[0]}")
|
|
1033
|
+
parts.append(f"Cwd: {os.getcwd()}")
|
|
1034
|
+
_info("\n".join(parts))
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
# ---------------------------------------------------------------------------
|
|
1038
|
+
# Async orchestrator
|
|
1039
|
+
# ---------------------------------------------------------------------------
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
async def _async_main(ns: argparse.Namespace) -> int:
|
|
1043
|
+
"""Wire everything together and dispatch to the chosen run mode."""
|
|
1044
|
+
from rlmagent.runtime import build_session
|
|
1045
|
+
|
|
1046
|
+
if ns.contract:
|
|
1047
|
+
from rlmagent.contract_run import run_contract
|
|
1048
|
+
|
|
1049
|
+
return await run_contract(ns)
|
|
1050
|
+
|
|
1051
|
+
# Piped stdin -> one-shot print mode
|
|
1052
|
+
if ns.prompt is None and not sys.stdin.isatty():
|
|
1053
|
+
ns.prompt = sys.stdin.read().strip()
|
|
1054
|
+
if not ns.prompt:
|
|
1055
|
+
_die("Empty input from stdin.")
|
|
1056
|
+
ns.print_mode = True
|
|
1057
|
+
|
|
1058
|
+
if ns.print_mode and ns.prompt is None:
|
|
1059
|
+
_die("Print mode requires a prompt (positional argument or stdin).")
|
|
1060
|
+
|
|
1061
|
+
# Shared construction — identical to the path the TUI uses.
|
|
1062
|
+
session = await build_session(ns)
|
|
1063
|
+
_wire_signals(session)
|
|
1064
|
+
|
|
1065
|
+
if not ns.print_mode:
|
|
1066
|
+
_banner(session.provider_name, session.model, session.session_id, ns.verbose)
|
|
1067
|
+
|
|
1068
|
+
# --- dispatch ------------------------------------------------------
|
|
1069
|
+
|
|
1070
|
+
try:
|
|
1071
|
+
if ns.prompt:
|
|
1072
|
+
return await _run_one_shot(session, ns.prompt, print_mode=ns.print_mode)
|
|
1073
|
+
return await _run_interactive(session)
|
|
1074
|
+
finally:
|
|
1075
|
+
await session.shutdown()
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# ---------------------------------------------------------------------------
|
|
1079
|
+
# Entry point
|
|
1080
|
+
# ---------------------------------------------------------------------------
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1084
|
+
"""Top-level CLI entry point for rlm-agent."""
|
|
1085
|
+
# Model output is Unicode; a redirected Windows stream defaults to a legacy codepage.
|
|
1086
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1087
|
+
if hasattr(stream, "reconfigure"):
|
|
1088
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
1089
|
+
args = list(argv if argv is not None else sys.argv[1:])
|
|
1090
|
+
if "--offline" in args:
|
|
1091
|
+
os.environ["RLM_AGENT_OFFLINE"] = "1"
|
|
1092
|
+
args.remove("--offline")
|
|
1093
|
+
from rlmagent.packages import activate
|
|
1094
|
+
|
|
1095
|
+
activate()
|
|
1096
|
+
|
|
1097
|
+
if "--acp" in args:
|
|
1098
|
+
from rlmagent.acp.server import run as serve_acp
|
|
1099
|
+
|
|
1100
|
+
args.remove("--acp")
|
|
1101
|
+
return serve_acp(args)
|
|
1102
|
+
|
|
1103
|
+
# Subcommand dispatch (checked before argparse to avoid positional conflicts)
|
|
1104
|
+
if args and args[0] in _SUBCOMMANDS:
|
|
1105
|
+
cmd, rest = args[0], args[1:]
|
|
1106
|
+
if cmd == "package":
|
|
1107
|
+
from rlmagent.packages import run
|
|
1108
|
+
|
|
1109
|
+
return run(rest)
|
|
1110
|
+
if cmd == "acp":
|
|
1111
|
+
from rlmagent.model.external.acp_install import main as acp_main
|
|
1112
|
+
return acp_main(rest)
|
|
1113
|
+
if cmd == "config":
|
|
1114
|
+
from rlmagent.cli.config_cmd import handle_config
|
|
1115
|
+
return handle_config(rest)
|
|
1116
|
+
if cmd == "factory":
|
|
1117
|
+
from rlmagent.factory import run
|
|
1118
|
+
|
|
1119
|
+
return run(rest)
|
|
1120
|
+
if cmd == "tui":
|
|
1121
|
+
ns = _build_run_parser().parse_args(rest)
|
|
1122
|
+
if ns.supervised or _saved_interactive(ns):
|
|
1123
|
+
return _handle_supervised(ns, rest, tui=True)
|
|
1124
|
+
return _handle_tui(ns)
|
|
1125
|
+
if cmd == "provider":
|
|
1126
|
+
return _handle_provider(rest)
|
|
1127
|
+
if cmd == "session":
|
|
1128
|
+
return _handle_session(rest)
|
|
1129
|
+
if cmd == "worker":
|
|
1130
|
+
from rlmagent.worker import handle
|
|
1131
|
+
|
|
1132
|
+
return handle(rest)
|
|
1133
|
+
if cmd == "update":
|
|
1134
|
+
from rlmagent.cli.update import run
|
|
1135
|
+
|
|
1136
|
+
return run(rest)
|
|
1137
|
+
if cmd in ("status", "doctor"):
|
|
1138
|
+
from rlmagent.cli.health import run
|
|
1139
|
+
|
|
1140
|
+
return run(rest, doctor=cmd == "doctor")
|
|
1141
|
+
|
|
1142
|
+
# Default: run mode
|
|
1143
|
+
ns = _build_run_parser().parse_args(args)
|
|
1144
|
+
|
|
1145
|
+
# A background worker owns the session only when asked for, or when the session
|
|
1146
|
+
# being resumed already has one; otherwise the full UI runs in this process.
|
|
1147
|
+
if ns.supervised or (_saved_interactive(ns) and sys.stdin.isatty()
|
|
1148
|
+
and _live_worker_owns(ns.resume)):
|
|
1149
|
+
tui = not ns.repl and sys.stdin.isatty() and sys.stdout.isatty() and _textual_available()
|
|
1150
|
+
return _handle_supervised(ns, args, tui=tui)
|
|
1151
|
+
|
|
1152
|
+
# Explicit foreground and no-session runs can still use the in-process TUI.
|
|
1153
|
+
if not ns.contract and _should_launch_tui(ns):
|
|
1154
|
+
return _handle_tui(ns)
|
|
1155
|
+
|
|
1156
|
+
try:
|
|
1157
|
+
return asyncio.run(_async_main(ns))
|
|
1158
|
+
except KeyboardInterrupt:
|
|
1159
|
+
_info("\nInterrupted.")
|
|
1160
|
+
return 130
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _live_worker_owns(session_id: str | None) -> bool:
|
|
1164
|
+
"""Whether a running background worker holds this saved session."""
|
|
1165
|
+
if not session_id:
|
|
1166
|
+
return False
|
|
1167
|
+
from rlmagent import worker
|
|
1168
|
+
|
|
1169
|
+
for name in worker._worker_names():
|
|
1170
|
+
meta = worker.state_of(name)
|
|
1171
|
+
if meta.get("session") == session_id and (meta["alive"] or worker._supervised(meta)):
|
|
1172
|
+
return True
|
|
1173
|
+
return False
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _saved_interactive(ns: argparse.Namespace) -> bool:
|
|
1177
|
+
return not (ns.foreground or ns.no_session or ns.contract or ns.print_mode or ns.prompt is not None)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _handle_supervised(ns: argparse.Namespace, args: list[str], *, tui: bool) -> int:
|
|
1181
|
+
if ns.no_session or ns.contract or ns.print_mode or ns.prompt is not None:
|
|
1182
|
+
_die("--supervised requires an interactive saved session")
|
|
1183
|
+
from rlmagent.worker import start_interactive
|
|
1184
|
+
|
|
1185
|
+
if tui and not _textual_available():
|
|
1186
|
+
_die("The TUI requires the 'textual' extra. Install it with: pip install rlmness-cli[tui]")
|
|
1187
|
+
try:
|
|
1188
|
+
return start_interactive(args, session_id=ns.resume, tui=tui)
|
|
1189
|
+
except ValueError as error:
|
|
1190
|
+
_die(str(error))
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
if __name__ == "__main__":
|
|
1194
|
+
raise SystemExit(main())
|