tenseleyflow/loader / cc0fe04

Browse files

Add runtime-owned shell API boundary

Authored by espadonne
SHA
cc0fe0413333f54d5de2e84797d0da8867feee3d
Parents
803dde9
Tree
008687d

4 changed files

StatusFile+-
M src/loader/cli/main.py 14 31
A src/loader/runtime/runtime_api.py 105 0
M src/loader/ui/app.py 2 35
M tests/test_cli_runtime_owner.py 35 20
src/loader/cli/main.pymodified
@@ -39,6 +39,7 @@ from ..runtime.inspection import (
3939
 )
4040
 from ..runtime.owner_metadata import format_runtime_owner_label
4141
 from ..runtime.permissions import PermissionMode
42
+from ..runtime.runtime_api import RuntimeShellOwner, build_runtime_shell_owner
4243
 from ..runtime.workflow_timeline_read_model import (
4344
     format_evidence_provenance_brief,
4445
     summarize_observed_verification,
@@ -188,31 +189,6 @@ def clean_response(text: str) -> str:
188189
     return text.strip()
189190
 
190191
 
191
-def _build_cli_shell_owner(
192
-    *,
193
-    backend,
194
-    registry,
195
-    config,
196
-    require_public_agent: bool,
197
-):
198
-    """Build the CLI runtime owner for the requested integration path.
199
-
200
-    Non-TUI CLI flows use the runtime-first internal handle so internal
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.
204
-    """
205
-
206
-    if require_public_agent:
207
-        from ..agent.loop import Agent
208
-
209
-        return Agent(backend=backend, registry=registry, config=config)
210
-
211
-    from ..runtime.runtime_handle import RuntimeHandle
212
-
213
-    return RuntimeHandle(backend=backend, registry=registry, config=config)
214
-
215
-
216192
 @click.command()
217193
 @click.option("--model", "-m", default=None, help="Model to use (default: llama3.1:8b)")
218194
 @click.option("--select-model", "-s", is_flag=True, help="Interactively select model from available")
@@ -391,11 +367,11 @@ async def _main(
391367
         reasoning=reasoning_config,
392368
     )
393369
     try:
394
-        shell_owner = _build_cli_shell_owner(
370
+        shell_owner = build_runtime_shell_owner(
395371
             backend=llm,
396372
             registry=registry,
397373
             config=config,
398
-            require_public_agent=False,
374
+            owner_kind="runtime",
399375
         )
400376
     except ValueError as exc:
401377
         console.print(f"[red]Permission policy error:[/red] {exc}")
@@ -514,7 +490,11 @@ def _format_tool_args(args: dict | None) -> str:
514490
     return ", ".join(parts)
515491
 
516492
 
517
-async def run_once(shell_owner, prompt: str, skip_confirmation: bool = False) -> None:
493
+async def run_once(
494
+    shell_owner: RuntimeShellOwner,
495
+    prompt: str,
496
+    skip_confirmation: bool = False,
497
+) -> None:
518498
     """Run a single prompt through one shell-compatible runtime owner."""
519499
     import time
520500
 
@@ -614,7 +594,10 @@ async def run_once(shell_owner, prompt: str, skip_confirmation: bool = False) ->
614594
             console.print("[red]Aborted.[/red]")
615595
 
616596
 
617
-async def run_interactive(shell_owner, skip_confirmation: bool = False) -> None:
597
+async def run_interactive(
598
+    shell_owner: RuntimeShellOwner,
599
+    skip_confirmation: bool = False,
600
+) -> None:
618601
     """Run the simple interactive chat loop for one shell-compatible owner."""
619602
     import os
620603
 
@@ -1283,7 +1266,7 @@ async def _explore_main(
12831266
     set_last_model(model)
12841267
 
12851268
     try:
1286
-        shell_owner = _build_cli_shell_owner(
1269
+        shell_owner = build_runtime_shell_owner(
12871270
             backend=llm,
12881271
             registry=None,
12891272
             config=AgentConfig(
@@ -1292,7 +1275,7 @@ async def _explore_main(
12921275
                 permission_mode=PermissionMode.READ_ONLY,
12931276
                 stream=False,
12941277
             ),
1295
-            require_public_agent=False,
1278
+            owner_kind="runtime",
12961279
         )
12971280
     except ValueError as exc:
12981281
         console.print(f"[red]Permission policy error:[/red] {exc}")
src/loader/runtime/runtime_api.pyadded
@@ -0,0 +1,105 @@
1
+"""Runtime-owned public shell contract and owner builder."""
2
+
3
+from __future__ import annotations
4
+
5
+from collections.abc import AsyncIterator, Awaitable, Callable
6
+from pathlib import Path
7
+from typing import Literal, Protocol
8
+
9
+from ..context.project import ProjectContext
10
+from ..tools.base import ToolRegistry
11
+from .capabilities import CapabilityProfile
12
+from .events import AgentEvent
13
+from .session import ConversationSession
14
+
15
+RuntimeOwnerKind = Literal["runtime", "public-compat"]
16
+
17
+
18
+class RuntimeShellOwner(Protocol):
19
+    """Narrow public shell contract shared by CLI, UI, and integrations."""
20
+
21
+    backend: object
22
+    registry: ToolRegistry
23
+    capability_profile: CapabilityProfile
24
+    safeguards: object
25
+    session: ConversationSession
26
+    project_context: ProjectContext | None
27
+    workflow_mode: str
28
+    active_permission_mode: str
29
+    use_react: bool
30
+    is_running: bool
31
+
32
+    def resume_session(self, session_id: str | None = None) -> bool:
33
+        """Resume the latest or named persisted session."""
34
+
35
+    def steer(self, message: str) -> bool:
36
+        """Queue one steering message while the owner is running."""
37
+
38
+    def refresh_capability_profile(self) -> None:
39
+        """Refresh the active capability profile."""
40
+
41
+    async def run(
42
+        self,
43
+        user_message: str,
44
+        on_event: (
45
+            Callable[[AgentEvent], None]
46
+            | Callable[[AgentEvent], Awaitable[None]]
47
+            | None
48
+        ) = None,
49
+        on_confirmation: Callable[[str, str, str], Awaitable[bool]] | None = None,
50
+        on_user_question: Callable[[str, list[str] | None], Awaitable[str]] | None = None,
51
+        use_plan: bool | None = None,
52
+    ) -> str:
53
+        """Run one user message through the shell owner."""
54
+
55
+    async def run_streaming(
56
+        self,
57
+        user_message: str,
58
+    ) -> AsyncIterator[AgentEvent]:
59
+        """Yield the streamed event sequence for one user message."""
60
+
61
+    async def run_explore(
62
+        self,
63
+        user_message: str,
64
+        on_event: (
65
+            Callable[[AgentEvent], None]
66
+            | Callable[[AgentEvent], Awaitable[None]]
67
+            | None
68
+        ) = None,
69
+        *,
70
+        fresh: bool = False,
71
+    ) -> str:
72
+        """Run one read-only explore query through the shell owner."""
73
+
74
+    def clear_history(self) -> None:
75
+        """Reset the owner history."""
76
+
77
+
78
+def build_runtime_shell_owner(
79
+    *,
80
+    backend,
81
+    registry,
82
+    config,
83
+    project_root: Path | str | None = None,
84
+    owner_kind: RuntimeOwnerKind = "runtime",
85
+) -> RuntimeShellOwner:
86
+    """Build the shared shell owner for runtime-first or compatibility paths."""
87
+
88
+    if owner_kind == "public-compat":
89
+        from ..agent.loop import Agent
90
+
91
+        return Agent(
92
+            backend=backend,
93
+            registry=registry,
94
+            config=config,
95
+            project_root=project_root,
96
+        )
97
+
98
+    from .runtime_handle import RuntimeHandle
99
+
100
+    return RuntimeHandle(
101
+        backend=backend,
102
+        registry=registry,
103
+        config=config,
104
+        project_root=project_root,
105
+    )
src/loader/ui/app.pymodified
@@ -2,9 +2,7 @@
22
 
33
 import asyncio
44
 import time
5
-from collections.abc import Awaitable, Callable
65
 from pathlib import Path
7
-from typing import Protocol
86
 
97
 from rich.markup import escape
108
 from textual import work
@@ -16,6 +14,7 @@ from textual.widgets import Footer, Input, Static
1614
 from textual.worker import Worker, get_current_worker
1715
 
1816
 from ..runtime.events import AgentEvent
17
+from ..runtime.runtime_api import RuntimeShellOwner
1918
 from .adapter import (
2019
     ArtifactCreated,
2120
     ClearStream,
@@ -52,38 +51,6 @@ from .widgets import (
5251
 )
5352
 
5453
 
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
-
8754
 class LoaderApp(App):
8855
     """Main Textual application for Loader."""
8956
 
@@ -100,7 +67,7 @@ class LoaderApp(App):
10067
 
10168
     def __init__(
10269
         self,
103
-        shell_owner: LoaderUIShellOwner,
70
+        shell_owner: RuntimeShellOwner,
10471
         model_name: str = "",
10572
         mode: str = "Native",
10673
         capability_profile: str = "",
tests/test_cli_runtime_owner.pymodified
@@ -9,6 +9,7 @@ import pytest
99
 
1010
 import loader.agent.loop as agent_loop_module
1111
 import loader.cli.main as cli_main_module
12
+import loader.runtime.runtime_api as runtime_api_module
1213
 import loader.runtime.runtime_handle as runtime_handle_module
1314
 from loader.agent.loop import AgentConfig
1415
 
@@ -34,7 +35,7 @@ def _install_fake_ollama_module(monkeypatch: pytest.MonkeyPatch) -> None:
3435
     monkeypatch.setitem(sys.modules, "loader.llm.ollama", module)
3536
 
3637
 
37
-def test_build_cli_shell_owner_uses_runtime_handle_for_internal_paths(
38
+def test_build_runtime_shell_owner_uses_runtime_handle_for_runtime_paths(
3839
     monkeypatch: pytest.MonkeyPatch,
3940
 ) -> None:
4041
     seen: list[dict[str, object]] = []
@@ -50,18 +51,25 @@ def test_build_cli_shell_owner_uses_runtime_handle_for_internal_paths(
5051
     monkeypatch.setattr(runtime_handle_module, "RuntimeHandle", FakeHandle)
5152
     monkeypatch.setattr(agent_loop_module, "Agent", FakeAgent)
5253
 
53
-    owner = cli_main_module._build_cli_shell_owner(
54
+    owner = runtime_api_module.build_runtime_shell_owner(
5455
         backend="backend",
5556
         registry="registry",
5657
         config="config",
57
-        require_public_agent=False,
58
+        owner_kind="runtime",
5859
     )
5960
 
6061
     assert isinstance(owner, FakeHandle)
61
-    assert seen == [{"backend": "backend", "registry": "registry", "config": "config"}]
62
+    assert seen == [
63
+        {
64
+            "backend": "backend",
65
+            "registry": "registry",
66
+            "config": "config",
67
+            "project_root": None,
68
+        }
69
+    ]
6270
 
6371
 
64
-def test_build_cli_shell_owner_uses_agent_for_public_paths(
72
+def test_build_runtime_shell_owner_uses_agent_for_public_compat_paths(
6573
     monkeypatch: pytest.MonkeyPatch,
6674
 ) -> None:
6775
     seen: list[dict[str, object]] = []
@@ -77,15 +85,22 @@ def test_build_cli_shell_owner_uses_agent_for_public_paths(
7785
     monkeypatch.setattr(runtime_handle_module, "RuntimeHandle", FakeHandle)
7886
     monkeypatch.setattr(agent_loop_module, "Agent", FakeAgent)
7987
 
80
-    owner = cli_main_module._build_cli_shell_owner(
88
+    owner = runtime_api_module.build_runtime_shell_owner(
8189
         backend="backend",
8290
         registry="registry",
8391
         config="config",
84
-        require_public_agent=True,
92
+        owner_kind="public-compat",
8593
     )
8694
 
8795
     assert isinstance(owner, FakeAgent)
88
-    assert seen == [{"backend": "backend", "registry": "registry", "config": "config"}]
96
+    assert seen == [
97
+        {
98
+            "backend": "backend",
99
+            "registry": "registry",
100
+            "config": "config",
101
+            "project_root": None,
102
+        }
103
+    ]
89104
 
90105
 
91106
 @pytest.mark.asyncio
@@ -125,18 +140,18 @@ async def test_main_uses_runtime_first_owner_for_tui_launch(
125140
     fake_ui_app.LoaderApp = FakeApp
126141
     monkeypatch.setitem(sys.modules, "loader.ui.app", fake_ui_app)
127142
 
128
-    def fake_build_owner(*, backend, registry, config, require_public_agent):
143
+    def fake_build_owner(*, backend, registry, config, owner_kind):
129144
         owner_calls.append(
130145
             {
131146
                 "backend": backend,
132147
                 "registry": registry,
133148
                 "config": config,
134
-                "require_public_agent": require_public_agent,
149
+                "owner_kind": owner_kind,
135150
             }
136151
         )
137152
         return fake_owner
138153
 
139
-    monkeypatch.setattr(cli_main_module, "_build_cli_shell_owner", fake_build_owner)
154
+    monkeypatch.setattr(cli_main_module, "build_runtime_shell_owner", fake_build_owner)
140155
 
141156
     await cli_main_module._main(
142157
         model="fake-model",
@@ -162,7 +177,7 @@ async def test_main_uses_runtime_first_owner_for_tui_launch(
162177
         prompt=None,
163178
     )
164179
 
165
-    assert owner_calls and owner_calls[0]["require_public_agent"] is False
180
+    assert owner_calls and owner_calls[0]["owner_kind"] == "runtime"
166181
     assert app_calls[0]["shell_owner"] is fake_owner
167182
     assert app_calls[-1] == {"ran": True}
168183
 
@@ -193,13 +208,13 @@ async def test_main_uses_runtime_first_owner_for_single_prompt(
193208
         lambda: SimpleNamespace(skip_confirmation=False),
194209
     )
195210
 
196
-    def fake_build_owner(*, backend, registry, config, require_public_agent):
211
+    def fake_build_owner(*, backend, registry, config, owner_kind):
197212
         seen.append(
198213
             {
199214
                 "backend": backend,
200215
                 "registry": registry,
201216
                 "config": config,
202
-                "require_public_agent": require_public_agent,
217
+                "owner_kind": owner_kind,
203218
             }
204219
         )
205220
         return fake_owner
@@ -211,7 +226,7 @@ async def test_main_uses_runtime_first_owner_for_single_prompt(
211226
         captured["prompt"] = prompt
212227
         captured["skip_confirmation"] = skip_confirmation
213228
 
214
-    monkeypatch.setattr(cli_main_module, "_build_cli_shell_owner", fake_build_owner)
229
+    monkeypatch.setattr(cli_main_module, "build_runtime_shell_owner", fake_build_owner)
215230
     monkeypatch.setattr(cli_main_module, "run_once", fake_run_once)
216231
 
217232
     await cli_main_module._main(
@@ -238,7 +253,7 @@ async def test_main_uses_runtime_first_owner_for_single_prompt(
238253
         prompt="Summarize the runtime-first shell path.",
239254
     )
240255
 
241
-    assert seen and seen[0]["require_public_agent"] is False
256
+    assert seen and seen[0]["owner_kind"] == "runtime"
242257
     assert isinstance(seen[0]["config"], AgentConfig)
243258
     assert captured == {
244259
         "owner": fake_owner,
@@ -267,18 +282,18 @@ async def test_explore_main_uses_runtime_first_owner(
267282
 
268283
     owner_calls: list[dict[str, object]] = []
269284
 
270
-    def fake_build_owner(*, backend, registry, config, require_public_agent):
285
+    def fake_build_owner(*, backend, registry, config, owner_kind):
271286
         owner_calls.append(
272287
             {
273288
                 "backend": backend,
274289
                 "registry": registry,
275290
                 "config": config,
276
-                "require_public_agent": require_public_agent,
291
+                "owner_kind": owner_kind,
277292
             }
278293
         )
279294
         return FakeExploreOwner()
280295
 
281
-    monkeypatch.setattr(cli_main_module, "_build_cli_shell_owner", fake_build_owner)
296
+    monkeypatch.setattr(cli_main_module, "build_runtime_shell_owner", fake_build_owner)
282297
 
283298
     await cli_main_module._explore_main(
284299
         model="fake-model",
@@ -293,7 +308,7 @@ async def test_explore_main_uses_runtime_first_owner(
293308
         prompt="Where should I start?",
294309
     )
295310
 
296
-    assert owner_calls and owner_calls[0]["require_public_agent"] is False
311
+    assert owner_calls and owner_calls[0]["owner_kind"] == "runtime"
297312
     assert isinstance(owner_calls[0]["config"], AgentConfig)
298313
     assert len(seen) == 1
299314
     assert seen[0]["prompt"] == "Where should I start?"