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.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]