tenseleyflow/loader / 8643315

Browse files

Move TUI onto the runtime shell-owner contract

Authored by espadonne
SHA
864331578d81573e5bb4e462373138b239c2e5ac
Parents
e1c5bfc
Tree
1a155fb

5 changed files

StatusFile+-
M src/loader/cli/main.py 5 6
M src/loader/runtime/runtime_handle.py 11 0
M src/loader/ui/adapter.py 1 1
M src/loader/ui/app.py 61 23
M tests/test_runtime_handle.py 19 0
src/loader/cli/main.pymodified
@@ -198,9 +198,9 @@ def _build_cli_shell_owner(
198198
     """Build the CLI runtime owner for the requested integration path.
199199
 
200200
     Non-TUI CLI flows use the runtime-first internal handle so internal
201
-    integrations stop depending on `Agent` by reflex. The Textual app still
202
-    receives the public `Agent` facade intentionally because it relies on the
203
-    documented public steering shell.
201
+    integrations stop depending on `Agent` by reflex. Public-shell construction
202
+    remains available for explicit compatibility paths, but the CLI can choose a
203
+    runtime-first owner even for interactive surfaces.
204204
     """
205205
 
206206
     if require_public_agent:
@@ -390,13 +390,12 @@ async def _main(
390390
         workflow_mode_override="clarify" if clarify else ("plan" if plan else None),
391391
         reasoning=reasoning_config,
392392
     )
393
-    require_public_agent = not no_tui and prompt is None
394393
     try:
395394
         shell_owner = _build_cli_shell_owner(
396395
             backend=llm,
397396
             registry=registry,
398397
             config=config,
399
-            require_public_agent=require_public_agent,
398
+            require_public_agent=False,
400399
         )
401400
     except ValueError as exc:
402401
         console.print(f"[red]Permission policy error:[/red] {exc}")
@@ -488,7 +487,7 @@ async def _main(
488487
         from ..ui.app import LoaderApp
489488
 
490489
         app = LoaderApp(
491
-            agent=shell_owner,
490
+            shell_owner=shell_owner,
492491
             model_name=model,
493492
             mode=mode_str,
494493
             capability_profile=(
src/loader/runtime/runtime_handle.pymodified
@@ -111,12 +111,23 @@ class RuntimeHandle:
111111
 
112112
         return self.permission_policy.rule_counts()
113113
 
114
+    @property
115
+    def is_running(self) -> bool:
116
+        """Return whether the runtime-owned shell is currently running."""
117
+
118
+        return self.steering.is_running
119
+
114120
     @property
115121
     def use_react(self) -> bool:
116122
         """Determine whether to use ReAct prompting or native tools."""
117123
 
118124
         return resolve_runtime_shell_use_react(self)
119125
 
126
+    def steer(self, message: str) -> bool:
127
+        """Queue one steering message when the runtime shell is active."""
128
+
129
+        return self.steering.steer(message)
130
+
120131
     def resume_session(self, session_id: str | None = None) -> bool:
121132
         """Resume the latest or named persisted session."""
122133
 
src/loader/ui/adapter.pymodified
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
55
 
66
 from textual.message import Message
77
 
8
-from ..agent.loop import AgentEvent
8
+from ..runtime.events import AgentEvent
99
 
1010
 if TYPE_CHECKING:
1111
     from ..runtime.reasoning_types import (
src/loader/ui/app.pymodified
@@ -2,7 +2,9 @@
22
 
33
 import asyncio
44
 import time
5
+from collections.abc import Awaitable, Callable
56
 from pathlib import Path
7
+from typing import Protocol
68
 
79
 from rich.markup import escape
810
 from textual import work
@@ -13,7 +15,7 @@ from textual.reactive import reactive
1315
 from textual.widgets import Footer, Input, Static
1416
 from textual.worker import Worker, get_current_worker
1517
 
16
-from ..agent.loop import Agent, AgentEvent
18
+from ..runtime.events import AgentEvent
1719
 from .adapter import (
1820
     ArtifactCreated,
1921
     ClearStream,
@@ -50,6 +52,38 @@ from .widgets import (
5052
 )
5153
 
5254
 
55
+class LoaderUIShellOwner(Protocol):
56
+    """Small shell-owner contract used by the Textual UI."""
57
+
58
+    backend: object
59
+    capability_profile: object
60
+    safeguards: object
61
+    is_running: bool
62
+
63
+    def steer(self, message: str) -> bool:
64
+        """Queue one steering message while the owner is running."""
65
+
66
+    def refresh_capability_profile(self) -> None:
67
+        """Refresh the active capability profile."""
68
+
69
+    async def run(
70
+        self,
71
+        user_message: str,
72
+        on_event: (
73
+            Callable[[AgentEvent], None]
74
+            | Callable[[AgentEvent], Awaitable[None]]
75
+            | None
76
+        ) = None,
77
+        on_confirmation: Callable[[str, str, str], Awaitable[bool]] | None = None,
78
+        on_user_question: Callable[[str, list[str] | None], Awaitable[str]] | None = None,
79
+        use_plan: bool | None = None,
80
+    ) -> str:
81
+        """Run one user message through the shell owner."""
82
+
83
+    def clear_history(self) -> None:
84
+        """Reset the owner history."""
85
+
86
+
5387
 class LoaderApp(App):
5488
     """Main Textual application for Loader."""
5589
 
@@ -66,7 +100,7 @@ class LoaderApp(App):
66100
 
67101
     def __init__(
68102
         self,
69
-        agent: Agent,
103
+        shell_owner: LoaderUIShellOwner,
70104
         model_name: str = "",
71105
         mode: str = "Native",
72106
         capability_profile: str = "",
@@ -77,7 +111,7 @@ class LoaderApp(App):
77111
         **kwargs,
78112
     ) -> None:
79113
         super().__init__(**kwargs)
80
-        self.agent = agent
114
+        self.shell_owner = shell_owner
81115
         self.model_name = model_name
82116
         self.mode = mode
83117
         self.capability_profile = capability_profile
@@ -185,14 +219,14 @@ class LoaderApp(App):
185219
             self.action_clear_messages()
186220
             return
187221
 
188
-        # If agent is running, this is a steering message
189
-        if self.is_generating and self.agent.is_running:
222
+        # If the runtime owner is running, this is a steering message
223
+        if self.is_generating and self.shell_owner.is_running:
190224
             # Finalize current streaming so new content appears below user's message
191225
             if self._current_streaming is not None:
192226
                 self._current_streaming.stop_streaming()
193227
                 self._current_streaming = None
194228
             self._add_steering_message(user_input)
195
-            self.agent.steer(user_input)
229
+            self.shell_owner.steer(user_input)
196230
             return
197231
 
198232
         # Add user message to display
@@ -278,11 +312,15 @@ class LoaderApp(App):
278312
 
279313
         try:
280314
             models = []
281
-            if hasattr(self.agent.backend, "list_models"):
282
-                models = await self.agent.backend.list_models()
315
+            if hasattr(self.shell_owner.backend, "list_models"):
316
+                models = await self.shell_owner.backend.list_models()
283317
 
284318
             if models:
285
-                current = self.agent.backend.model if hasattr(self.agent.backend, "model") else ""
319
+                current = (
320
+                    self.shell_owner.backend.model
321
+                    if hasattr(self.shell_owner.backend, "model")
322
+                    else ""
323
+                )
286324
 
287325
                 def on_select(selected: str | None) -> None:
288326
                     if selected:
@@ -299,24 +337,24 @@ class LoaderApp(App):
299337
 
300338
     def _switch_model(self, model_name: str) -> None:
301339
         """Switch to a different model."""
302
-        if hasattr(self.agent.backend, "model"):
303
-            old_model = self.agent.backend.model
304
-            self.agent.backend.model = model_name
305
-            if hasattr(self.agent, "refresh_capability_profile"):
306
-                self.agent.refresh_capability_profile()
340
+        if hasattr(self.shell_owner.backend, "model"):
341
+            old_model = self.shell_owner.backend.model
342
+            self.shell_owner.backend.model = model_name
343
+            if hasattr(self.shell_owner, "refresh_capability_profile"):
344
+                self.shell_owner.refresh_capability_profile()
307345
             self.model_name = model_name
308346
             # Update status line
309347
             status = self.query_one(StatusLine)
310348
             status.model = model_name
311349
             # Update mode based on new model's capabilities
312
-            if hasattr(self.agent.backend, "supports_native_tools"):
313
-                supports_native = self.agent.backend.supports_native_tools()
350
+            if hasattr(self.shell_owner.backend, "supports_native_tools"):
351
+                supports_native = self.shell_owner.backend.supports_native_tools()
314352
                 self.mode = "Native" if supports_native else "ReAct"
315353
                 status.mode = self.mode
316
-            if hasattr(self.agent, "capability_profile"):
354
+            if hasattr(self.shell_owner, "capability_profile"):
317355
                 self.capability_profile = (
318
-                    f"{self.agent.capability_profile.preferred_tool_call_format}/"
319
-                    f"{self.agent.capability_profile.verification_strictness}"
356
+                    f"{self.shell_owner.capability_profile.preferred_tool_call_format}/"
357
+                    f"{self.shell_owner.capability_profile.verification_strictness}"
320358
                 )
321359
                 status.capability_profile = self.capability_profile
322360
             self._add_message(
@@ -469,7 +507,7 @@ class LoaderApp(App):
469507
                 await asyncio.sleep(0)
470508
 
471509
         async def on_confirmation(tool_name: str, message: str, details: str) -> bool:
472
-            """Handle confirmation requests from agent."""
510
+            """Handle confirmation requests from the runtime owner."""
473511
             if worker.is_cancelled:
474512
                 return False
475513
             return await self._request_confirmation(tool_name, message, details)
@@ -485,7 +523,7 @@ class LoaderApp(App):
485523
             return await self._request_user_question(question, options)
486524
 
487525
         try:
488
-            return await self.agent.run(
526
+            return await self.shell_owner.run(
489527
                 user_input,
490528
                 on_event=on_event,
491529
                 on_confirmation=on_confirmation,
@@ -550,7 +588,7 @@ class LoaderApp(App):
550588
 
551589
         # Filter content through safeguards before displaying
552590
         # This removes bracket tool calls, code blocks, etc. from stream
553
-        filtered_content = self.agent.safeguards.filter_stream_chunk(message.content)
591
+        filtered_content = self.shell_owner.safeguards.filter_stream_chunk(message.content)
554592
 
555593
         if filtered_content:
556594
             self._current_streaming.append(filtered_content)
@@ -853,7 +891,7 @@ class LoaderApp(App):
853891
         """Clear all messages."""
854892
         msg_area = self.query_one("#message-area", ScrollableContainer)
855893
         msg_area.remove_children()
856
-        self.agent.clear_history()
894
+        self.shell_owner.clear_history()
857895
         self.query_one(StatusLine).clear_definition_of_done()
858896
         self.query_one(StatusLine).update_workflow_mode("execute")
859897
         self._add_message("[dim]Conversation cleared.[/dim]")
tests/test_runtime_handle.pymodified
@@ -162,3 +162,22 @@ async def test_runtime_harness_uses_runtime_handle_for_scripted_runs(
162162
     assert run.response == "Runtime harness reply."
163163
     assert isinstance(explore_run.agent, RuntimeHandle)
164164
     assert explore_run.response == "Explore harness reply."
165
+
166
+
167
+def test_runtime_handle_exposes_public_shell_steering_contract(
168
+    temp_dir: Path,
169
+) -> None:
170
+    handle = RuntimeHandle(
171
+        backend=ScriptedBackend(),
172
+        config=AgentConfig(auto_context=False),
173
+        project_root=temp_dir,
174
+    )
175
+
176
+    assert handle.is_running is False
177
+    assert handle.steer("stay in runtime") is False
178
+
179
+    handle.steering.mark_running()
180
+
181
+    assert handle.is_running is True
182
+    assert handle.steer("stay in runtime") is True
183
+    assert handle.drain_steering_messages() == ["stay in runtime"]