rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/tui/adapter.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Adapter boundary: ``AgentEvent`` stream -> TUI state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from time import time
|
|
11
|
+
|
|
12
|
+
from rlmagent.conversation import CodingSession
|
|
13
|
+
from rlmagent.harness.contracts.stream import (
|
|
14
|
+
AgentEvent,
|
|
15
|
+
MessageEndEvent,
|
|
16
|
+
MessageUpdateEvent,
|
|
17
|
+
RetryEvent,
|
|
18
|
+
ToolRunEndEvent,
|
|
19
|
+
ToolRunStartEvent,
|
|
20
|
+
)
|
|
21
|
+
from rlmagent.harness.contracts.transcript import ModelEntry
|
|
22
|
+
from rlmagent.tui.steps import StepBook
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# View-model updates
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class TextDelta:
|
|
31
|
+
"""Incremental assistant text to append to the live block."""
|
|
32
|
+
|
|
33
|
+
text: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class TurnFinished:
|
|
38
|
+
"""The assistant message completed."""
|
|
39
|
+
|
|
40
|
+
text: str
|
|
41
|
+
error: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ToolStarted:
|
|
46
|
+
"""A tool invocation began."""
|
|
47
|
+
|
|
48
|
+
call_id: str
|
|
49
|
+
name: str
|
|
50
|
+
summary: str
|
|
51
|
+
code: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ToolFinished:
|
|
56
|
+
"""A tool invocation completed."""
|
|
57
|
+
|
|
58
|
+
call_id: str
|
|
59
|
+
name: str
|
|
60
|
+
output: str
|
|
61
|
+
is_error: bool
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class StepChanged:
|
|
66
|
+
"""An agent step started, produced code or output, or finished."""
|
|
67
|
+
|
|
68
|
+
step: object # rlmagent.tui.steps.Step
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class AgentNote:
|
|
73
|
+
"""Progress an agent reported, or messages other agents sent this session."""
|
|
74
|
+
|
|
75
|
+
text: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class RetryNotice:
|
|
80
|
+
"""A failed attempt is being retried; shown as progress, not as an error."""
|
|
81
|
+
|
|
82
|
+
message: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
Update = (TextDelta | TurnFinished | ToolStarted | ToolFinished | RetryNotice | StepChanged
|
|
86
|
+
| AgentNote)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Status snapshot
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _worker_states() -> dict[str, str]:
|
|
95
|
+
"""Session id -> running/idle for sessions a live background worker holds."""
|
|
96
|
+
try:
|
|
97
|
+
from rlmagent.worker import _worker_names, state_of
|
|
98
|
+
except ImportError:
|
|
99
|
+
return {}
|
|
100
|
+
states: dict[str, str] = {}
|
|
101
|
+
try:
|
|
102
|
+
for name in _worker_names():
|
|
103
|
+
meta = state_of(name)
|
|
104
|
+
if meta.get("alive") and meta.get("session"):
|
|
105
|
+
states[str(meta["session"])] = "running" if meta.get("state") == "busy" else "idle"
|
|
106
|
+
except OSError:
|
|
107
|
+
return states
|
|
108
|
+
return states
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class SessionEntry:
|
|
113
|
+
"""One row in the session sidebar."""
|
|
114
|
+
|
|
115
|
+
session_id: str
|
|
116
|
+
title: str | None
|
|
117
|
+
updated_at: float
|
|
118
|
+
is_active: bool
|
|
119
|
+
#: running (a turn is going), idle (open here or held by a live worker),
|
|
120
|
+
#: or inactive (only saved).
|
|
121
|
+
status: str = "inactive"
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def label(self) -> str:
|
|
125
|
+
"""Display name — the title, or a neutral placeholder when unnamed."""
|
|
126
|
+
return self.title or "Untitled session"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class StatusSnapshot:
|
|
131
|
+
"""Everything the footer displays."""
|
|
132
|
+
|
|
133
|
+
provider: str
|
|
134
|
+
model: str
|
|
135
|
+
context_used: int
|
|
136
|
+
context_limit: int
|
|
137
|
+
branch: str | None
|
|
138
|
+
plan_mode: bool = False
|
|
139
|
+
input_tokens: int = 0
|
|
140
|
+
cached_tokens: int = 0
|
|
141
|
+
output_tokens: int = 0
|
|
142
|
+
cost: float = 0.0
|
|
143
|
+
budget: str = ""
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def context_percent(self) -> int:
|
|
147
|
+
"""Context consumption as a whole percentage."""
|
|
148
|
+
if self.context_limit <= 0:
|
|
149
|
+
return 0
|
|
150
|
+
return min(100, round(self.context_used * 100 / self.context_limit))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Tool call summaries
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
_PATH_ARGS = ("file_path", "path", "notebook_path")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def summarize_call(name: str, args: dict) -> str:
|
|
161
|
+
"""One-line description of a tool call, e.g. ``Read foo.py``."""
|
|
162
|
+
for key in _PATH_ARGS:
|
|
163
|
+
value = args.get(key)
|
|
164
|
+
if isinstance(value, str) and value:
|
|
165
|
+
return f"{name} {value.rsplit('/', 1)[-1].rsplit(chr(92), 1)[-1]}"
|
|
166
|
+
command = args.get("command")
|
|
167
|
+
if isinstance(command, str) and command:
|
|
168
|
+
first = command.strip().splitlines()[0]
|
|
169
|
+
return f"{name} {first[:60]}"
|
|
170
|
+
pattern = args.get("pattern")
|
|
171
|
+
if isinstance(pattern, str) and pattern:
|
|
172
|
+
return f"{name} {pattern[:40]}"
|
|
173
|
+
return name
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Bridge
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class SessionBridge:
|
|
182
|
+
"""Translates a ``CodingSession`` event stream into view-model updates."""
|
|
183
|
+
|
|
184
|
+
__slots__ = ("_session", "_streamed", "steps")
|
|
185
|
+
|
|
186
|
+
def __init__(self, session: CodingSession) -> None:
|
|
187
|
+
self._session = session
|
|
188
|
+
self._streamed = 0
|
|
189
|
+
self.steps = StepBook()
|
|
190
|
+
|
|
191
|
+
# -- read-only views ----------------------------------------------------
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def session(self) -> CodingSession:
|
|
195
|
+
"""The underlying runtime session."""
|
|
196
|
+
return self._session
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def active(self) -> bool:
|
|
200
|
+
"""Whether a generation is currently running."""
|
|
201
|
+
return self._session.active
|
|
202
|
+
|
|
203
|
+
def status(self) -> StatusSnapshot:
|
|
204
|
+
"""Snapshot the footer state."""
|
|
205
|
+
from rlmagent.runtime import git_branch
|
|
206
|
+
|
|
207
|
+
usage = self._session.usage
|
|
208
|
+
return StatusSnapshot(
|
|
209
|
+
provider=self._session.provider_name,
|
|
210
|
+
model=self._session.model,
|
|
211
|
+
context_used=usage.estimated_context_tokens,
|
|
212
|
+
context_limit=usage.context_limit,
|
|
213
|
+
branch=git_branch(self._session.cwd),
|
|
214
|
+
plan_mode=self._session.plan_mode,
|
|
215
|
+
input_tokens=usage.total_input_tokens,
|
|
216
|
+
cached_tokens=usage.cache_read_tokens,
|
|
217
|
+
output_tokens=usage.total_output_tokens,
|
|
218
|
+
cost=usage.total_cost_usd,
|
|
219
|
+
budget=self.budget_line(),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def budget_line(self) -> str:
|
|
223
|
+
"""Steps, agents, depth and spend of the current run against its limits."""
|
|
224
|
+
totals = self.steps.totals()
|
|
225
|
+
if not totals["steps"]:
|
|
226
|
+
return ""
|
|
227
|
+
backend = getattr(self._session, "backend", None)
|
|
228
|
+
limits = getattr(getattr(backend, "tree", None), "allowance", None)
|
|
229
|
+
|
|
230
|
+
def of(used, limit):
|
|
231
|
+
return f"{used}/{limit}" if limit is not None else f"{used}"
|
|
232
|
+
|
|
233
|
+
parts = [f"steps {of(totals['steps'], getattr(limits, 'max_calls', None))}"]
|
|
234
|
+
if totals["running"]:
|
|
235
|
+
parts.append(f"{totals['running']} running")
|
|
236
|
+
parts.append(f"depth {of(totals['depth'], getattr(limits, 'max_depth', None))}")
|
|
237
|
+
if totals["cost"] is not None:
|
|
238
|
+
cap = getattr(limits, "max_cost", None)
|
|
239
|
+
parts.append(f"${totals['cost']:.4f}" + (f"/{cap:.2f}" if cap is not None else ""))
|
|
240
|
+
return " ".join(parts)
|
|
241
|
+
|
|
242
|
+
def sessions(self, busy: bool = False) -> list[SessionEntry]:
|
|
243
|
+
"""List every known session in a stable order, newest first."""
|
|
244
|
+
catalog = self._session.catalog
|
|
245
|
+
if catalog is None:
|
|
246
|
+
return []
|
|
247
|
+
active = self._session.session_id
|
|
248
|
+
# A session belongs to the project it started in; never offer another project's here.
|
|
249
|
+
here = Path(self._session.cwd).resolve()
|
|
250
|
+
metas = sorted(
|
|
251
|
+
(m for m in catalog.list_all() if not m.cwd or Path(m.cwd).resolve() == here),
|
|
252
|
+
key=lambda m: m.created_at, reverse=True,
|
|
253
|
+
)
|
|
254
|
+
entries = [
|
|
255
|
+
SessionEntry(
|
|
256
|
+
session_id=meta.session_id,
|
|
257
|
+
title=meta.title,
|
|
258
|
+
updated_at=meta.updated_at,
|
|
259
|
+
is_active=meta.session_id == active,
|
|
260
|
+
)
|
|
261
|
+
for meta in metas
|
|
262
|
+
]
|
|
263
|
+
# A new session is only saved once something happens in it; until
|
|
264
|
+
# then it is still the one being worked in, so it leads the list.
|
|
265
|
+
if not any(entry.is_active for entry in entries):
|
|
266
|
+
entries.insert(0, SessionEntry(
|
|
267
|
+
session_id=active, title=self._session.title,
|
|
268
|
+
updated_at=time(), is_active=True,
|
|
269
|
+
))
|
|
270
|
+
held = _worker_states()
|
|
271
|
+
return [
|
|
272
|
+
replace(entry, status=(
|
|
273
|
+
("running" if busy else "idle") if entry.is_active
|
|
274
|
+
else held.get(entry.session_id, "inactive")
|
|
275
|
+
))
|
|
276
|
+
for entry in entries
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
# -- actions ------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def cancel(self) -> None:
|
|
282
|
+
"""Cancel the active generation via the runtime's own mechanism."""
|
|
283
|
+
self._session.abort()
|
|
284
|
+
|
|
285
|
+
async def switch_to(self, session_id: str) -> None:
|
|
286
|
+
"""Switch to an existing session via the runtime, reusing the provider."""
|
|
287
|
+
if session_id == self._session.session_id:
|
|
288
|
+
return
|
|
289
|
+
self._session = await self._session.replace_session(session_id)
|
|
290
|
+
self._streamed = 0
|
|
291
|
+
|
|
292
|
+
async def start_new(self) -> None:
|
|
293
|
+
"""Create a fresh session via the runtime, reusing the provider."""
|
|
294
|
+
self._session = await self._session.new_session()
|
|
295
|
+
self._streamed = 0
|
|
296
|
+
|
|
297
|
+
async def apply_model_choice(
|
|
298
|
+
self, provider: str, model: str, api_key: str | None, base_url: str | None = None,
|
|
299
|
+
) -> str:
|
|
300
|
+
"""Persist credentials, rebuild the provider, and switch the session."""
|
|
301
|
+
from rlmagent.config.config import resolve_provider
|
|
302
|
+
from rlmagent.config.loader import apply_config
|
|
303
|
+
from rlmagent.config.store import load_config, provider_info, remember_model, save_config
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
info = provider_info(provider)
|
|
307
|
+
except KeyError:
|
|
308
|
+
# Not one of the configurable backends (e.g. a scripted provider
|
|
309
|
+
# in tests). Only the model can change.
|
|
310
|
+
await self._session.switch_model(model)
|
|
311
|
+
return f"Switched to {provider} · {model}"
|
|
312
|
+
|
|
313
|
+
# Save the key (and the selection) so the next launch reuses them.
|
|
314
|
+
config = load_config()
|
|
315
|
+
if api_key:
|
|
316
|
+
config.api_keys[provider] = api_key
|
|
317
|
+
if base_url is not None:
|
|
318
|
+
config.base_urls[provider] = base_url
|
|
319
|
+
config.provider = provider
|
|
320
|
+
config.model = model
|
|
321
|
+
save_config(config)
|
|
322
|
+
remember_model(provider, model)
|
|
323
|
+
|
|
324
|
+
# Export credentials for this process. These are set unconditionally:
|
|
325
|
+
# an explicit switch must override whatever the previous provider left
|
|
326
|
+
# behind (notably the base URL shared by OpenAI-compatible backends).
|
|
327
|
+
import os
|
|
328
|
+
|
|
329
|
+
if provider == "anthropic":
|
|
330
|
+
key_var, url_var = "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"
|
|
331
|
+
else:
|
|
332
|
+
key_var, url_var = "OPENAI_API_KEY", "OPENAI_BASE_URL"
|
|
333
|
+
|
|
334
|
+
if provider not in ("vertex", "codex", "google", "mistral", "azure", "copilot", "claude-subscription", "xai", "xai-subscription"):
|
|
335
|
+
key = api_key or config.api_keys.get(provider) or os.environ.get(info.key_env)
|
|
336
|
+
if key:
|
|
337
|
+
os.environ[key_var] = key
|
|
338
|
+
elif not info.needs_key:
|
|
339
|
+
os.environ[key_var] = provider # e.g. Ollama needs a placeholder
|
|
340
|
+
os.environ[url_var] = config.base_urls.get(provider) or info.base_url
|
|
341
|
+
apply_config(provider, config)
|
|
342
|
+
|
|
343
|
+
if provider == self._session.provider_name and base_url is None:
|
|
344
|
+
await self._session.switch_model(model)
|
|
345
|
+
else:
|
|
346
|
+
await self._session.switch_provider(
|
|
347
|
+
resolve_provider(provider), provider, model,
|
|
348
|
+
)
|
|
349
|
+
return f"Switched to {info.label} · {model}"
|
|
350
|
+
|
|
351
|
+
async def shutdown(self) -> None:
|
|
352
|
+
"""Shut down the session currently held."""
|
|
353
|
+
await self._session.shutdown()
|
|
354
|
+
|
|
355
|
+
async def handle_command(self, text: str) -> str | None:
|
|
356
|
+
"""Delegate a slash command to the runtime."""
|
|
357
|
+
return await self._session.handle_command(text)
|
|
358
|
+
|
|
359
|
+
async def submit(self, text: str) -> AsyncIterator[Update]:
|
|
360
|
+
"""Send *text* and yield view-model updates as the runtime streams."""
|
|
361
|
+
net = getattr(self._session, "agentnet", None)
|
|
362
|
+
if net is not None:
|
|
363
|
+
from rlmagent.agentnet import inbox_note
|
|
364
|
+
|
|
365
|
+
net.beat()
|
|
366
|
+
if messages := net.take():
|
|
367
|
+
note = inbox_note(messages)
|
|
368
|
+
self._session.inject(note)
|
|
369
|
+
yield AgentNote(note)
|
|
370
|
+
async for update in self._relay(self._session.submit(text)):
|
|
371
|
+
yield update
|
|
372
|
+
|
|
373
|
+
async def resume(self) -> AsyncIterator[Update]:
|
|
374
|
+
"""Continue the agent loop without adding a new user message."""
|
|
375
|
+
async for update in self._relay(self._session.resume_run()):
|
|
376
|
+
yield update
|
|
377
|
+
|
|
378
|
+
async def _relay(self, events: AsyncIterator[AgentEvent]) -> AsyncIterator[Update]:
|
|
379
|
+
"""Translate a runtime event stream into view-model updates.
|
|
380
|
+
|
|
381
|
+
Step events arrive on the engine's thread while the reply streams, so
|
|
382
|
+
both are put on one queue and handed on in the order they came.
|
|
383
|
+
"""
|
|
384
|
+
self._streamed = 0
|
|
385
|
+
# Cleared rather than replaced: an open agent tree keeps this book.
|
|
386
|
+
self.steps.clear()
|
|
387
|
+
backend = getattr(self._session, "backend", None)
|
|
388
|
+
if backend is None or not hasattr(backend, "listeners"):
|
|
389
|
+
# The session engine's tree reports its children the same way.
|
|
390
|
+
backend = getattr(self._session, "agent_tree", None)
|
|
391
|
+
if backend is None or not isinstance(getattr(backend, "listeners", None), list):
|
|
392
|
+
async for event in events:
|
|
393
|
+
update = self._translate(event)
|
|
394
|
+
if update is not None:
|
|
395
|
+
yield update
|
|
396
|
+
return
|
|
397
|
+
loop = asyncio.get_running_loop()
|
|
398
|
+
merged: asyncio.Queue = asyncio.Queue()
|
|
399
|
+
finished = object()
|
|
400
|
+
|
|
401
|
+
def on_event(event: dict) -> None:
|
|
402
|
+
loop.call_soon_threadsafe(merged.put_nowait, ("step", event))
|
|
403
|
+
|
|
404
|
+
async def pump() -> None:
|
|
405
|
+
try:
|
|
406
|
+
async for event in events:
|
|
407
|
+
await merged.put(("agent", event))
|
|
408
|
+
except BaseException as error: # handed to the reader below
|
|
409
|
+
await merged.put(("error", error))
|
|
410
|
+
finally:
|
|
411
|
+
await merged.put(("done", finished))
|
|
412
|
+
|
|
413
|
+
backend.listeners.append(on_event)
|
|
414
|
+
cards = hasattr(backend, "cards")
|
|
415
|
+
if cards:
|
|
416
|
+
backend.cards = True
|
|
417
|
+
task = asyncio.create_task(pump())
|
|
418
|
+
try:
|
|
419
|
+
while True:
|
|
420
|
+
kind, item = await merged.get()
|
|
421
|
+
if kind == "done":
|
|
422
|
+
break
|
|
423
|
+
if kind == "error":
|
|
424
|
+
raise item
|
|
425
|
+
if kind == "step" and item.get("kind") == "progress":
|
|
426
|
+
update = AgentNote(f"progress: {item.get('text', '')}")
|
|
427
|
+
elif kind == "step":
|
|
428
|
+
update = StepChanged(step) if (step := self.steps.feed(item)) else None
|
|
429
|
+
else:
|
|
430
|
+
update = self._translate(item)
|
|
431
|
+
if update is not None:
|
|
432
|
+
yield update
|
|
433
|
+
while not merged.empty(): # steps that landed after the reply ended
|
|
434
|
+
kind, item = merged.get_nowait()
|
|
435
|
+
if kind == "step" and (step := self.steps.feed(item)):
|
|
436
|
+
yield StepChanged(step)
|
|
437
|
+
finally:
|
|
438
|
+
backend.listeners.remove(on_event)
|
|
439
|
+
if cards:
|
|
440
|
+
backend.cards = False
|
|
441
|
+
if not task.done():
|
|
442
|
+
task.cancel()
|
|
443
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
444
|
+
await task
|
|
445
|
+
|
|
446
|
+
# -- translation --------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def _translate(self, event: AgentEvent) -> Update | None:
|
|
449
|
+
"""Map one ``AgentEvent`` onto a view-model update."""
|
|
450
|
+
if isinstance(event, MessageUpdateEvent):
|
|
451
|
+
return self._on_update(event)
|
|
452
|
+
if isinstance(event, MessageEndEvent):
|
|
453
|
+
return self._on_end(event)
|
|
454
|
+
if isinstance(event, ToolRunStartEvent):
|
|
455
|
+
return ToolStarted(
|
|
456
|
+
call_id=event.tool_call_id,
|
|
457
|
+
name=event.tool_name,
|
|
458
|
+
summary=summarize_call(event.tool_name, event.args),
|
|
459
|
+
code=str(event.args.get("code") or "") if event.tool_name == "python" else "",
|
|
460
|
+
)
|
|
461
|
+
if isinstance(event, RetryEvent):
|
|
462
|
+
return RetryNotice(event.message)
|
|
463
|
+
if isinstance(event, ToolRunEndEvent):
|
|
464
|
+
return ToolFinished(
|
|
465
|
+
call_id=event.tool_call_id,
|
|
466
|
+
name=event.tool_name,
|
|
467
|
+
output=event.result.text,
|
|
468
|
+
is_error=event.is_error,
|
|
469
|
+
)
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
def _on_update(self, event: MessageUpdateEvent) -> Update | None:
|
|
473
|
+
"""Emit only the newly streamed characters."""
|
|
474
|
+
message = event.message
|
|
475
|
+
if not isinstance(message, ModelEntry):
|
|
476
|
+
return None
|
|
477
|
+
full = message.text
|
|
478
|
+
if len(full) <= self._streamed:
|
|
479
|
+
return None
|
|
480
|
+
delta = full[self._streamed:]
|
|
481
|
+
self._streamed = len(full)
|
|
482
|
+
return TextDelta(delta)
|
|
483
|
+
|
|
484
|
+
def _on_end(self, event: MessageEndEvent) -> Update | None:
|
|
485
|
+
"""Finalize the assistant block, flushing any untransmitted tail."""
|
|
486
|
+
message = event.message
|
|
487
|
+
if not isinstance(message, ModelEntry):
|
|
488
|
+
return None
|
|
489
|
+
self._streamed = 0
|
|
490
|
+
return TurnFinished(text=message.text, error=message.error_message)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
__all__ = [
|
|
494
|
+
"AgentNote",
|
|
495
|
+
"RetryNotice",
|
|
496
|
+
"SessionBridge",
|
|
497
|
+
"SessionEntry",
|
|
498
|
+
"StatusSnapshot",
|
|
499
|
+
"StepChanged",
|
|
500
|
+
"TextDelta",
|
|
501
|
+
"ToolFinished",
|
|
502
|
+
"ToolStarted",
|
|
503
|
+
"TurnFinished",
|
|
504
|
+
"Update",
|
|
505
|
+
"summarize_call",
|
|
506
|
+
]
|