@@ -2,7 +2,9 @@ |
| 2 | 2 | |
| 3 | 3 | import asyncio |
| 4 | 4 | import time |
| 5 | +from collections.abc import Awaitable, Callable |
| 5 | 6 | from pathlib import Path |
| 7 | +from typing import Protocol |
| 6 | 8 | |
| 7 | 9 | from rich.markup import escape |
| 8 | 10 | from textual import work |
@@ -13,7 +15,7 @@ from textual.reactive import reactive |
| 13 | 15 | from textual.widgets import Footer, Input, Static |
| 14 | 16 | from textual.worker import Worker, get_current_worker |
| 15 | 17 | |
| 16 | | -from ..agent.loop import Agent, AgentEvent |
| 18 | +from ..runtime.events import AgentEvent |
| 17 | 19 | from .adapter import ( |
| 18 | 20 | ArtifactCreated, |
| 19 | 21 | ClearStream, |
@@ -50,6 +52,38 @@ from .widgets import ( |
| 50 | 52 | ) |
| 51 | 53 | |
| 52 | 54 | |
| 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 | + |
| 53 | 87 | class LoaderApp(App): |
| 54 | 88 | """Main Textual application for Loader.""" |
| 55 | 89 | |
@@ -66,7 +100,7 @@ class LoaderApp(App): |
| 66 | 100 | |
| 67 | 101 | def __init__( |
| 68 | 102 | self, |
| 69 | | - agent: Agent, |
| 103 | + shell_owner: LoaderUIShellOwner, |
| 70 | 104 | model_name: str = "", |
| 71 | 105 | mode: str = "Native", |
| 72 | 106 | capability_profile: str = "", |
@@ -77,7 +111,7 @@ class LoaderApp(App): |
| 77 | 111 | **kwargs, |
| 78 | 112 | ) -> None: |
| 79 | 113 | super().__init__(**kwargs) |
| 80 | | - self.agent = agent |
| 114 | + self.shell_owner = shell_owner |
| 81 | 115 | self.model_name = model_name |
| 82 | 116 | self.mode = mode |
| 83 | 117 | self.capability_profile = capability_profile |
@@ -185,14 +219,14 @@ class LoaderApp(App): |
| 185 | 219 | self.action_clear_messages() |
| 186 | 220 | return |
| 187 | 221 | |
| 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: |
| 190 | 224 | # Finalize current streaming so new content appears below user's message |
| 191 | 225 | if self._current_streaming is not None: |
| 192 | 226 | self._current_streaming.stop_streaming() |
| 193 | 227 | self._current_streaming = None |
| 194 | 228 | self._add_steering_message(user_input) |
| 195 | | - self.agent.steer(user_input) |
| 229 | + self.shell_owner.steer(user_input) |
| 196 | 230 | return |
| 197 | 231 | |
| 198 | 232 | # Add user message to display |
@@ -278,11 +312,15 @@ class LoaderApp(App): |
| 278 | 312 | |
| 279 | 313 | try: |
| 280 | 314 | 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() |
| 283 | 317 | |
| 284 | 318 | 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 | + ) |
| 286 | 324 | |
| 287 | 325 | def on_select(selected: str | None) -> None: |
| 288 | 326 | if selected: |
@@ -299,24 +337,24 @@ class LoaderApp(App): |
| 299 | 337 | |
| 300 | 338 | def _switch_model(self, model_name: str) -> None: |
| 301 | 339 | """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() |
| 307 | 345 | self.model_name = model_name |
| 308 | 346 | # Update status line |
| 309 | 347 | status = self.query_one(StatusLine) |
| 310 | 348 | status.model = model_name |
| 311 | 349 | # 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() |
| 314 | 352 | self.mode = "Native" if supports_native else "ReAct" |
| 315 | 353 | status.mode = self.mode |
| 316 | | - if hasattr(self.agent, "capability_profile"): |
| 354 | + if hasattr(self.shell_owner, "capability_profile"): |
| 317 | 355 | 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}" |
| 320 | 358 | ) |
| 321 | 359 | status.capability_profile = self.capability_profile |
| 322 | 360 | self._add_message( |
@@ -469,7 +507,7 @@ class LoaderApp(App): |
| 469 | 507 | await asyncio.sleep(0) |
| 470 | 508 | |
| 471 | 509 | 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.""" |
| 473 | 511 | if worker.is_cancelled: |
| 474 | 512 | return False |
| 475 | 513 | return await self._request_confirmation(tool_name, message, details) |
@@ -485,7 +523,7 @@ class LoaderApp(App): |
| 485 | 523 | return await self._request_user_question(question, options) |
| 486 | 524 | |
| 487 | 525 | try: |
| 488 | | - return await self.agent.run( |
| 526 | + return await self.shell_owner.run( |
| 489 | 527 | user_input, |
| 490 | 528 | on_event=on_event, |
| 491 | 529 | on_confirmation=on_confirmation, |
@@ -550,7 +588,7 @@ class LoaderApp(App): |
| 550 | 588 | |
| 551 | 589 | # Filter content through safeguards before displaying |
| 552 | 590 | # 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) |
| 554 | 592 | |
| 555 | 593 | if filtered_content: |
| 556 | 594 | self._current_streaming.append(filtered_content) |
@@ -853,7 +891,7 @@ class LoaderApp(App): |
| 853 | 891 | """Clear all messages.""" |
| 854 | 892 | msg_area = self.query_one("#message-area", ScrollableContainer) |
| 855 | 893 | msg_area.remove_children() |
| 856 | | - self.agent.clear_history() |
| 894 | + self.shell_owner.clear_history() |
| 857 | 895 | self.query_one(StatusLine).clear_definition_of_done() |
| 858 | 896 | self.query_one(StatusLine).update_workflow_mode("execute") |
| 859 | 897 | self._add_message("[dim]Conversation cleared.[/dim]") |