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 (
39
 )
39
 )
40
 from ..runtime.owner_metadata import format_runtime_owner_label
40
 from ..runtime.owner_metadata import format_runtime_owner_label
41
 from ..runtime.permissions import PermissionMode
41
 from ..runtime.permissions import PermissionMode
42
+from ..runtime.runtime_api import RuntimeShellOwner, build_runtime_shell_owner
42
 from ..runtime.workflow_timeline_read_model import (
43
 from ..runtime.workflow_timeline_read_model import (
43
     format_evidence_provenance_brief,
44
     format_evidence_provenance_brief,
44
     summarize_observed_verification,
45
     summarize_observed_verification,
@@ -188,31 +189,6 @@ def clean_response(text: str) -> str:
188
     return text.strip()
189
     return text.strip()
189
 
190
 
190
 
191
 
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
-
216
 @click.command()
192
 @click.command()
217
 @click.option("--model", "-m", default=None, help="Model to use (default: llama3.1:8b)")
193
 @click.option("--model", "-m", default=None, help="Model to use (default: llama3.1:8b)")
218
 @click.option("--select-model", "-s", is_flag=True, help="Interactively select model from available")
194
 @click.option("--select-model", "-s", is_flag=True, help="Interactively select model from available")
@@ -391,11 +367,11 @@ async def _main(
391
         reasoning=reasoning_config,
367
         reasoning=reasoning_config,
392
     )
368
     )
393
     try:
369
     try:
394
-        shell_owner = _build_cli_shell_owner(
370
+        shell_owner = build_runtime_shell_owner(
395
             backend=llm,
371
             backend=llm,
396
             registry=registry,
372
             registry=registry,
397
             config=config,
373
             config=config,
398
-            require_public_agent=False,
374
+            owner_kind="runtime",
399
         )
375
         )
400
     except ValueError as exc:
376
     except ValueError as exc:
401
         console.print(f"[red]Permission policy error:[/red] {exc}")
377
         console.print(f"[red]Permission policy error:[/red] {exc}")
@@ -514,7 +490,11 @@ def _format_tool_args(args: dict | None) -> str:
514
     return ", ".join(parts)
490
     return ", ".join(parts)
515
 
491
 
516
 
492
 
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:
518
     """Run a single prompt through one shell-compatible runtime owner."""
498
     """Run a single prompt through one shell-compatible runtime owner."""
519
     import time
499
     import time
520
 
500
 
@@ -614,7 +594,10 @@ async def run_once(shell_owner, prompt: str, skip_confirmation: bool = False) ->
614
             console.print("[red]Aborted.[/red]")
594
             console.print("[red]Aborted.[/red]")
615
 
595
 
616
 
596
 
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:
618
     """Run the simple interactive chat loop for one shell-compatible owner."""
601
     """Run the simple interactive chat loop for one shell-compatible owner."""
619
     import os
602
     import os
620
 
603
 
@@ -1283,7 +1266,7 @@ async def _explore_main(
1283
     set_last_model(model)
1266
     set_last_model(model)
1284
 
1267
 
1285
     try:
1268
     try:
1286
-        shell_owner = _build_cli_shell_owner(
1269
+        shell_owner = build_runtime_shell_owner(
1287
             backend=llm,
1270
             backend=llm,
1288
             registry=None,
1271
             registry=None,
1289
             config=AgentConfig(
1272
             config=AgentConfig(
@@ -1292,7 +1275,7 @@ async def _explore_main(
1292
                 permission_mode=PermissionMode.READ_ONLY,
1275
                 permission_mode=PermissionMode.READ_ONLY,
1293
                 stream=False,
1276
                 stream=False,
1294
             ),
1277
             ),
1295
-            require_public_agent=False,
1278
+            owner_kind="runtime",
1296
         )
1279
         )
1297
     except ValueError as exc:
1280
     except ValueError as exc:
1298
         console.print(f"[red]Permission policy error:[/red] {exc}")
1281
         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 @@
2
 
2
 
3
 import asyncio
3
 import asyncio
4
 import time
4
 import time
5
-from collections.abc import Awaitable, Callable
6
 from pathlib import Path
5
 from pathlib import Path
7
-from typing import Protocol
8
 
6
 
9
 from rich.markup import escape
7
 from rich.markup import escape
10
 from textual import work
8
 from textual import work
@@ -16,6 +14,7 @@ from textual.widgets import Footer, Input, Static
16
 from textual.worker import Worker, get_current_worker
14
 from textual.worker import Worker, get_current_worker
17
 
15
 
18
 from ..runtime.events import AgentEvent
16
 from ..runtime.events import AgentEvent
17
+from ..runtime.runtime_api import RuntimeShellOwner
19
 from .adapter import (
18
 from .adapter import (
20
     ArtifactCreated,
19
     ArtifactCreated,
21
     ClearStream,
20
     ClearStream,
@@ -52,38 +51,6 @@ from .widgets import (
52
 )
51
 )
53
 
52
 
54
 
53
 
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
-
87
 class LoaderApp(App):
54
 class LoaderApp(App):
88
     """Main Textual application for Loader."""
55
     """Main Textual application for Loader."""
89
 
56
 
@@ -100,7 +67,7 @@ class LoaderApp(App):
100
 
67
 
101
     def __init__(
68
     def __init__(
102
         self,
69
         self,
103
-        shell_owner: LoaderUIShellOwner,
70
+        shell_owner: RuntimeShellOwner,
104
         model_name: str = "",
71
         model_name: str = "",
105
         mode: str = "Native",
72
         mode: str = "Native",
106
         capability_profile: str = "",
73
         capability_profile: str = "",
tests/test_cli_runtime_owner.pymodified
@@ -9,6 +9,7 @@ import pytest
9
 
9
 
10
 import loader.agent.loop as agent_loop_module
10
 import loader.agent.loop as agent_loop_module
11
 import loader.cli.main as cli_main_module
11
 import loader.cli.main as cli_main_module
12
+import loader.runtime.runtime_api as runtime_api_module
12
 import loader.runtime.runtime_handle as runtime_handle_module
13
 import loader.runtime.runtime_handle as runtime_handle_module
13
 from loader.agent.loop import AgentConfig
14
 from loader.agent.loop import AgentConfig
14
 
15
 
@@ -34,7 +35,7 @@ def _install_fake_ollama_module(monkeypatch: pytest.MonkeyPatch) -> None:
34
     monkeypatch.setitem(sys.modules, "loader.llm.ollama", module)
35
     monkeypatch.setitem(sys.modules, "loader.llm.ollama", module)
35
 
36
 
36
 
37
 
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(
38
     monkeypatch: pytest.MonkeyPatch,
39
     monkeypatch: pytest.MonkeyPatch,
39
 ) -> None:
40
 ) -> None:
40
     seen: list[dict[str, object]] = []
41
     seen: list[dict[str, object]] = []
@@ -50,18 +51,25 @@ def test_build_cli_shell_owner_uses_runtime_handle_for_internal_paths(
50
     monkeypatch.setattr(runtime_handle_module, "RuntimeHandle", FakeHandle)
51
     monkeypatch.setattr(runtime_handle_module, "RuntimeHandle", FakeHandle)
51
     monkeypatch.setattr(agent_loop_module, "Agent", FakeAgent)
52
     monkeypatch.setattr(agent_loop_module, "Agent", FakeAgent)
52
 
53
 
53
-    owner = cli_main_module._build_cli_shell_owner(
54
+    owner = runtime_api_module.build_runtime_shell_owner(
54
         backend="backend",
55
         backend="backend",
55
         registry="registry",
56
         registry="registry",
56
         config="config",
57
         config="config",
57
-        require_public_agent=False,
58
+        owner_kind="runtime",
58
     )
59
     )
59
 
60
 
60
     assert isinstance(owner, FakeHandle)
61
     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
+    ]
62
 
70
 
63
 
71
 
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(
65
     monkeypatch: pytest.MonkeyPatch,
73
     monkeypatch: pytest.MonkeyPatch,
66
 ) -> None:
74
 ) -> None:
67
     seen: list[dict[str, object]] = []
75
     seen: list[dict[str, object]] = []
@@ -77,15 +85,22 @@ def test_build_cli_shell_owner_uses_agent_for_public_paths(
77
     monkeypatch.setattr(runtime_handle_module, "RuntimeHandle", FakeHandle)
85
     monkeypatch.setattr(runtime_handle_module, "RuntimeHandle", FakeHandle)
78
     monkeypatch.setattr(agent_loop_module, "Agent", FakeAgent)
86
     monkeypatch.setattr(agent_loop_module, "Agent", FakeAgent)
79
 
87
 
80
-    owner = cli_main_module._build_cli_shell_owner(
88
+    owner = runtime_api_module.build_runtime_shell_owner(
81
         backend="backend",
89
         backend="backend",
82
         registry="registry",
90
         registry="registry",
83
         config="config",
91
         config="config",
84
-        require_public_agent=True,
92
+        owner_kind="public-compat",
85
     )
93
     )
86
 
94
 
87
     assert isinstance(owner, FakeAgent)
95
     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
+    ]
89
 
104
 
90
 
105
 
91
 @pytest.mark.asyncio
106
 @pytest.mark.asyncio
@@ -125,18 +140,18 @@ async def test_main_uses_runtime_first_owner_for_tui_launch(
125
     fake_ui_app.LoaderApp = FakeApp
140
     fake_ui_app.LoaderApp = FakeApp
126
     monkeypatch.setitem(sys.modules, "loader.ui.app", fake_ui_app)
141
     monkeypatch.setitem(sys.modules, "loader.ui.app", fake_ui_app)
127
 
142
 
128
-    def fake_build_owner(*, backend, registry, config, require_public_agent):
143
+    def fake_build_owner(*, backend, registry, config, owner_kind):
129
         owner_calls.append(
144
         owner_calls.append(
130
             {
145
             {
131
                 "backend": backend,
146
                 "backend": backend,
132
                 "registry": registry,
147
                 "registry": registry,
133
                 "config": config,
148
                 "config": config,
134
-                "require_public_agent": require_public_agent,
149
+                "owner_kind": owner_kind,
135
             }
150
             }
136
         )
151
         )
137
         return fake_owner
152
         return fake_owner
138
 
153
 
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)
140
 
155
 
141
     await cli_main_module._main(
156
     await cli_main_module._main(
142
         model="fake-model",
157
         model="fake-model",
@@ -162,7 +177,7 @@ async def test_main_uses_runtime_first_owner_for_tui_launch(
162
         prompt=None,
177
         prompt=None,
163
     )
178
     )
164
 
179
 
165
-    assert owner_calls and owner_calls[0]["require_public_agent"] is False
180
+    assert owner_calls and owner_calls[0]["owner_kind"] == "runtime"
166
     assert app_calls[0]["shell_owner"] is fake_owner
181
     assert app_calls[0]["shell_owner"] is fake_owner
167
     assert app_calls[-1] == {"ran": True}
182
     assert app_calls[-1] == {"ran": True}
168
 
183
 
@@ -193,13 +208,13 @@ async def test_main_uses_runtime_first_owner_for_single_prompt(
193
         lambda: SimpleNamespace(skip_confirmation=False),
208
         lambda: SimpleNamespace(skip_confirmation=False),
194
     )
209
     )
195
 
210
 
196
-    def fake_build_owner(*, backend, registry, config, require_public_agent):
211
+    def fake_build_owner(*, backend, registry, config, owner_kind):
197
         seen.append(
212
         seen.append(
198
             {
213
             {
199
                 "backend": backend,
214
                 "backend": backend,
200
                 "registry": registry,
215
                 "registry": registry,
201
                 "config": config,
216
                 "config": config,
202
-                "require_public_agent": require_public_agent,
217
+                "owner_kind": owner_kind,
203
             }
218
             }
204
         )
219
         )
205
         return fake_owner
220
         return fake_owner
@@ -211,7 +226,7 @@ async def test_main_uses_runtime_first_owner_for_single_prompt(
211
         captured["prompt"] = prompt
226
         captured["prompt"] = prompt
212
         captured["skip_confirmation"] = skip_confirmation
227
         captured["skip_confirmation"] = skip_confirmation
213
 
228
 
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)
215
     monkeypatch.setattr(cli_main_module, "run_once", fake_run_once)
230
     monkeypatch.setattr(cli_main_module, "run_once", fake_run_once)
216
 
231
 
217
     await cli_main_module._main(
232
     await cli_main_module._main(
@@ -238,7 +253,7 @@ async def test_main_uses_runtime_first_owner_for_single_prompt(
238
         prompt="Summarize the runtime-first shell path.",
253
         prompt="Summarize the runtime-first shell path.",
239
     )
254
     )
240
 
255
 
241
-    assert seen and seen[0]["require_public_agent"] is False
256
+    assert seen and seen[0]["owner_kind"] == "runtime"
242
     assert isinstance(seen[0]["config"], AgentConfig)
257
     assert isinstance(seen[0]["config"], AgentConfig)
243
     assert captured == {
258
     assert captured == {
244
         "owner": fake_owner,
259
         "owner": fake_owner,
@@ -267,18 +282,18 @@ async def test_explore_main_uses_runtime_first_owner(
267
 
282
 
268
     owner_calls: list[dict[str, object]] = []
283
     owner_calls: list[dict[str, object]] = []
269
 
284
 
270
-    def fake_build_owner(*, backend, registry, config, require_public_agent):
285
+    def fake_build_owner(*, backend, registry, config, owner_kind):
271
         owner_calls.append(
286
         owner_calls.append(
272
             {
287
             {
273
                 "backend": backend,
288
                 "backend": backend,
274
                 "registry": registry,
289
                 "registry": registry,
275
                 "config": config,
290
                 "config": config,
276
-                "require_public_agent": require_public_agent,
291
+                "owner_kind": owner_kind,
277
             }
292
             }
278
         )
293
         )
279
         return FakeExploreOwner()
294
         return FakeExploreOwner()
280
 
295
 
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)
282
 
297
 
283
     await cli_main_module._explore_main(
298
     await cli_main_module._explore_main(
284
         model="fake-model",
299
         model="fake-model",
@@ -293,7 +308,7 @@ async def test_explore_main_uses_runtime_first_owner(
293
         prompt="Where should I start?",
308
         prompt="Where should I start?",
294
     )
309
     )
295
 
310
 
296
-    assert owner_calls and owner_calls[0]["require_public_agent"] is False
311
+    assert owner_calls and owner_calls[0]["owner_kind"] == "runtime"
297
     assert isinstance(owner_calls[0]["config"], AgentConfig)
312
     assert isinstance(owner_calls[0]["config"], AgentConfig)
298
     assert len(seen) == 1
313
     assert len(seen) == 1
299
     assert seen[0]["prompt"] == "Where should I start?"
314
     assert seen[0]["prompt"] == "Where should I start?"