tenseleyflow/loader / c694b5a

Browse files

Persist runtime owner metadata in sessions

Authored by espadonne
SHA
c694b5a28f9d20ef9e96c1b1b4926176cd65db28
Parents
69d958e
Tree
6af580d

9 changed files

StatusFile+-
M src/loader/runtime/bootstrap.py 2 1
A src/loader/runtime/owner_metadata.py 73 0
M src/loader/runtime/public_shell.py 21 0
M src/loader/runtime/session.py 33 1
M tests/test_runtime_bootstrap.py 8 2
M tests/test_runtime_handle.py 8 2
M tests/test_runtime_launcher.py 4 1
M tests/test_runtime_public_shell.py 10 0
M tests/test_session_state.py 33 0
src/loader/runtime/bootstrap.pymodified
@@ -17,6 +17,7 @@ from .context import (
1717
     RuntimeSafeguardsProtocol,
1818
 )
1919
 from .events import TurnSummary
20
+from .owner_metadata import build_runtime_owner_metadata
2021
 from .permissions import PermissionConfigStatus, PermissionPolicy
2122
 from .reasoning_service import RuntimeReasoningService
2223
 from .session import ConversationSession
@@ -174,7 +175,7 @@ def build_runtime_bootstrap_source(source: RuntimeBootstrapSource | Any) -> Runt
174175
         _queue_steering_message=source.queue_steering_message,
175176
         _drain_steering_messages=source.drain_steering_messages,
176177
         _refresh_capability_profile=source.refresh_capability_profile,
177
-        metadata={"owner_type": type(source).__name__},
178
+        metadata=build_runtime_owner_metadata(source),
178179
     )
179180
 
180181
 
src/loader/runtime/owner_metadata.pyadded
@@ -0,0 +1,73 @@
1
+"""Shared helpers for runtime-owner metadata."""
2
+
3
+from __future__ import annotations
4
+
5
+from typing import Any
6
+
7
+_KNOWN_RUNTIME_OWNER_PATHS = {
8
+    "Agent": "public-agent",
9
+    "RuntimeHandle": "runtime-handle",
10
+}
11
+
12
+
13
+def normalize_runtime_owner_type(value: Any) -> str | None:
14
+    """Coerce persisted runtime-owner types into optional text."""
15
+
16
+    if value is None:
17
+        return None
18
+    text = str(value).strip()
19
+    return text or None
20
+
21
+
22
+def normalize_runtime_owner_path(
23
+    value: Any,
24
+    *,
25
+    owner_type: str | None = None,
26
+) -> str | None:
27
+    """Coerce persisted runtime-owner paths into canonical text."""
28
+
29
+    if value is not None:
30
+        text = str(value).strip()
31
+        if text:
32
+            return text
33
+    if owner_type is None:
34
+        return None
35
+    return _KNOWN_RUNTIME_OWNER_PATHS.get(owner_type, _camel_to_kebab(owner_type))
36
+
37
+
38
+def build_runtime_owner_metadata(source: Any) -> dict[str, str | None]:
39
+    """Build canonical runtime-owner metadata from one shell owner."""
40
+
41
+    owner_type = (
42
+        normalize_runtime_owner_type(source)
43
+        if isinstance(source, str)
44
+        else normalize_runtime_owner_type(type(source).__name__)
45
+    )
46
+    return {
47
+        "owner_type": owner_type,
48
+        "owner_path": normalize_runtime_owner_path(None, owner_type=owner_type),
49
+    }
50
+
51
+
52
+def format_runtime_owner_label(
53
+    owner_type: str | None,
54
+    owner_path: str | None,
55
+) -> str | None:
56
+    """Render one compact human-readable runtime-owner label."""
57
+
58
+    normalized_type = normalize_runtime_owner_type(owner_type)
59
+    normalized_path = normalize_runtime_owner_path(owner_path, owner_type=normalized_type)
60
+    if normalized_type and normalized_path:
61
+        return f"{normalized_path} ({normalized_type})"
62
+    return normalized_path or normalized_type
63
+
64
+
65
+def _camel_to_kebab(value: str) -> str:
66
+    """Convert one CamelCase-ish class name into kebab-case."""
67
+
68
+    chars: list[str] = []
69
+    for index, char in enumerate(value):
70
+        if char.isupper() and index > 0:
71
+            chars.append("-")
72
+        chars.append(char.lower())
73
+    return "".join(chars)
src/loader/runtime/public_shell.pymodified
@@ -18,6 +18,7 @@ from .capabilities import CapabilityProfile, resolve_backend_capability_profile
1818
 from .dod import DefinitionOfDoneStore
1919
 from .events import AgentEvent, TurnSummary
2020
 from .launcher import build_runtime_launcher
21
+from .owner_metadata import build_runtime_owner_metadata
2122
 from .permissions import PermissionConfigStatus, PermissionMode, PermissionPolicy
2223
 from .prompt_history import PromptSnapshot
2324
 from .prompting import build_system_prompt_result
@@ -174,6 +175,8 @@ def create_runtime_session(
174175
     prompt_format: str | None,
175176
     prompt_sections: list[str],
176177
     workflow_mode: str,
178
+    runtime_owner_type: str | None,
179
+    runtime_owner_path: str | None,
177180
     rotate_after_bytes: int,
178181
     auto_compaction_input_tokens_threshold: int,
179182
     compaction_keep_last_messages: int,
@@ -187,6 +190,8 @@ def create_runtime_session(
187190
         few_shot_factory=few_shot_factory,
188191
         project_root=project_root,
189192
         messages=messages or [],
193
+        runtime_owner_type=runtime_owner_type,
194
+        runtime_owner_path=runtime_owner_path,
190195
         permission_mode=permission_policy.active_mode.as_str(),
191196
         permission_prompting_enabled=permission_policy.prompting_enabled,
192197
         permission_rule_counts=_copy_rule_counts(permission_policy.rule_counts()),
@@ -211,6 +216,8 @@ def create_runtime_session_install(
211216
     prompt_format: str | None,
212217
     prompt_sections: list[str],
213218
     workflow_mode: str,
219
+    runtime_owner_type: str | None,
220
+    runtime_owner_path: str | None,
214221
     rotate_after_bytes: int,
215222
     auto_compaction_input_tokens_threshold: int,
216223
     compaction_keep_last_messages: int,
@@ -227,6 +234,8 @@ def create_runtime_session_install(
227234
         prompt_format=prompt_format,
228235
         prompt_sections=prompt_sections,
229236
         workflow_mode=workflow_mode,
237
+        runtime_owner_type=runtime_owner_type,
238
+        runtime_owner_path=runtime_owner_path,
230239
         rotate_after_bytes=rotate_after_bytes,
231240
         auto_compaction_input_tokens_threshold=(
232241
             auto_compaction_input_tokens_threshold
@@ -262,6 +271,15 @@ def apply_runtime_session_install(
262271
     owner.prompt_sections = list(install.restored.prompt_sections)
263272
     owner.last_turn_summary = install.restored.last_turn_summary
264273
     owner._system_message = None
274
+    owner_metadata = build_runtime_owner_metadata(owner)
275
+    if (
276
+        install.session.runtime_owner_type != owner_metadata["owner_type"]
277
+        or install.session.runtime_owner_path != owner_metadata["owner_path"]
278
+    ):
279
+        install.session.update_runtime_state(
280
+            runtime_owner_type=owner_metadata["owner_type"],
281
+            runtime_owner_path=owner_metadata["owner_path"],
282
+        )
265283
 
266284
 
267285
 def build_fresh_runtime_session_install(
@@ -272,6 +290,7 @@ def build_fresh_runtime_session_install(
272290
 ) -> RuntimeSessionInstall:
273291
     """Build a fresh runtime session install from the current public shell."""
274292
 
293
+    owner_metadata = build_runtime_owner_metadata(owner)
275294
     return create_runtime_session_install(
276295
         project_root=owner.project_root,
277296
         messages=messages,
@@ -280,6 +299,8 @@ def build_fresh_runtime_session_install(
280299
         prompt_format=owner.prompt_format,
281300
         prompt_sections=list(owner.prompt_sections),
282301
         workflow_mode=workflow_mode or owner.workflow_mode,
302
+        runtime_owner_type=owner_metadata["owner_type"],
303
+        runtime_owner_path=owner_metadata["owner_path"],
283304
         rotate_after_bytes=owner.config.session_rotate_after_bytes,
284305
         auto_compaction_input_tokens_threshold=(
285306
             owner.config.session_auto_compaction_input_tokens_threshold
src/loader/runtime/session.pymodified
@@ -24,11 +24,15 @@ from .completion_trace import (
2424
     has_canonical_completion_trace,
2525
     normalize_completion_trace,
2626
 )
27
+from .owner_metadata import (
28
+    normalize_runtime_owner_path,
29
+    normalize_runtime_owner_type,
30
+)
2731
 from .prompt_history import PromptSnapshot, normalize_prompt_history
2832
 from .workflow_ledger import WorkflowLedger
2933
 from .workflow_policy import WorkflowTimelineEntry
3034
 
31
-SESSION_VERSION = 10
35
+SESSION_VERSION = 11
3236
 DEFAULT_ROTATE_AFTER_BYTES = 256 * 1024
3337
 MAX_ROTATED_FILES = 3
3438
 _UNSET = object()
@@ -173,6 +177,8 @@ class SessionSnapshot:
173177
     usage: dict[str, int] = field(default_factory=dict)
174178
     active_dod_path: str | None = None
175179
     current_task: str | None = None
180
+    runtime_owner_type: str | None = None
181
+    runtime_owner_path: str | None = None
176182
     workflow_mode: str = "execute"
177183
     permission_mode: str = "workspace-write"
178184
     permission_prompting_enabled: bool = False
@@ -211,6 +217,8 @@ class SessionSnapshot:
211217
             "usage": dict(self.usage),
212218
             "active_dod_path": self.active_dod_path,
213219
             "current_task": self.current_task,
220
+            "runtime_owner_type": self.runtime_owner_type,
221
+            "runtime_owner_path": self.runtime_owner_path,
214222
             "workflow_mode": self.workflow_mode,
215223
             "permission_mode": self.permission_mode,
216224
             "permission_prompting_enabled": self.permission_prompting_enabled,
@@ -265,6 +273,15 @@ class SessionSnapshot:
265273
             usage=normalize_usage(data.get("usage")),
266274
             active_dod_path=data.get("active_dod_path"),
267275
             current_task=data.get("current_task"),
276
+            runtime_owner_type=normalize_runtime_owner_type(
277
+                data.get("runtime_owner_type")
278
+            ),
279
+            runtime_owner_path=normalize_runtime_owner_path(
280
+                data.get("runtime_owner_path"),
281
+                owner_type=normalize_runtime_owner_type(
282
+                    data.get("runtime_owner_type")
283
+                ),
284
+            ),
268285
             workflow_mode=str(data.get("workflow_mode", "execute")),
269286
             permission_mode=str(data.get("permission_mode", "workspace-write")),
270287
             permission_prompting_enabled=bool(
@@ -434,6 +451,8 @@ class ConversationSession:
434451
     usage_totals: dict[str, int] = field(default_factory=dict)
435452
     active_dod_path: str | None = None
436453
     current_task: str | None = None
454
+    runtime_owner_type: str | None = None
455
+    runtime_owner_path: str | None = None
437456
     workflow_mode: str = "execute"
438457
     permission_mode: str = "workspace-write"
439458
     permission_prompting_enabled: bool = False
@@ -568,6 +587,8 @@ class ConversationSession:
568587
         *,
569588
         active_dod_path: str | None = None,
570589
         current_task: str | None = None,
590
+        runtime_owner_type: str | None = None,
591
+        runtime_owner_path: str | None = None,
571592
         workflow_mode: str | None = None,
572593
         permission_mode: str | None = None,
573594
         permission_prompting_enabled: bool | None = None,
@@ -594,6 +615,13 @@ class ConversationSession:
594615
             self.active_dod_path = active_dod_path
595616
         if current_task is not None:
596617
             self.current_task = current_task
618
+        if runtime_owner_type is not None:
619
+            self.runtime_owner_type = normalize_runtime_owner_type(runtime_owner_type)
620
+        if runtime_owner_path is not None or runtime_owner_type is not None:
621
+            self.runtime_owner_path = normalize_runtime_owner_path(
622
+                runtime_owner_path,
623
+                owner_type=self.runtime_owner_type,
624
+            )
597625
         if workflow_mode is not None:
598626
             self.workflow_mode = workflow_mode
599627
         if permission_mode is not None:
@@ -792,6 +820,8 @@ class ConversationSession:
792820
             usage=dict(self.usage_totals),
793821
             active_dod_path=self.active_dod_path,
794822
             current_task=self.current_task,
823
+            runtime_owner_type=self.runtime_owner_type,
824
+            runtime_owner_path=self.runtime_owner_path,
795825
             workflow_mode=self.workflow_mode,
796826
             permission_mode=self.permission_mode,
797827
             permission_prompting_enabled=self.permission_prompting_enabled,
@@ -855,6 +885,8 @@ class ConversationSession:
855885
         instance.usage_totals = dict(snapshot.usage)
856886
         instance.active_dod_path = snapshot.active_dod_path
857887
         instance.current_task = snapshot.current_task
888
+        instance.runtime_owner_type = snapshot.runtime_owner_type
889
+        instance.runtime_owner_path = snapshot.runtime_owner_path
858890
         instance.workflow_mode = snapshot.workflow_mode
859891
         instance.permission_mode = snapshot.permission_mode
860892
         instance.permission_prompting_enabled = snapshot.permission_prompting_enabled
tests/test_runtime_bootstrap.pymodified
@@ -41,7 +41,10 @@ def test_build_runtime_context_uses_shared_bootstrap_contract(
4141
     assert context.workflow_mode == agent.workflow_mode
4242
     assert context.prompt_format == agent.prompt_format
4343
     assert context.prompt_sections == agent.prompt_sections
44
-    assert source.metadata == {"owner_type": "Agent"}
44
+    assert source.metadata == {
45
+        "owner_type": "Agent",
46
+        "owner_path": "public-agent",
47
+    }
4548
 
4649
 
4750
 def test_sync_runtime_context_refreshes_prompt_and_capability_state(
@@ -142,4 +145,7 @@ def test_build_runtime_launcher_wraps_shared_bootstrap_source(
142145
     assert isinstance(launcher, RuntimeLauncher)
143146
     assert isinstance(launcher.source, RuntimeBootstrapView)
144147
     assert launcher.source is not agent
145
-    assert launcher.source.metadata == {"owner_type": "Agent"}
148
+    assert launcher.source.metadata == {
149
+        "owner_type": "Agent",
150
+        "owner_path": "public-agent",
151
+    }
tests/test_runtime_handle.pymodified
@@ -30,7 +30,10 @@ def test_runtime_handle_builds_runtime_bootstrap_contract(
3030
     assert isinstance(launcher, RuntimeLauncher)
3131
     assert isinstance(launcher.source, RuntimeBootstrapView)
3232
     assert launcher.source is not handle
33
-    assert launcher.source.metadata == {"owner_type": "RuntimeHandle"}
33
+    assert launcher.source.metadata == {
34
+        "owner_type": "RuntimeHandle",
35
+        "owner_path": "runtime-handle",
36
+    }
3437
     assert context.project_root == temp_dir.resolve()
3538
     assert context.backend is handle.backend
3639
     assert context.registry is handle.registry
@@ -63,7 +66,10 @@ async def test_runtime_handle_runs_conversation_runtime_without_agent(
6366
     )
6467
 
6568
     assert summary.final_response == "Runtime handle reply."
66
-    assert runtime.source.metadata == {"owner_type": "RuntimeHandle"}
69
+    assert runtime.source.metadata == {
70
+        "owner_type": "RuntimeHandle",
71
+        "owner_path": "runtime-handle",
72
+    }
6773
     assert any(event.type == "response" for event in events)
6874
 
6975
 
tests/test_runtime_launcher.pymodified
@@ -28,7 +28,10 @@ def test_build_runtime_launcher_returns_launcher_for_agent_source(
2828
     assert isinstance(launcher, RuntimeLauncher)
2929
     assert isinstance(launcher.source, RuntimeBootstrapView)
3030
     assert launcher.source is not agent
31
-    assert launcher.source.metadata == {"owner_type": "Agent"}
31
+    assert launcher.source.metadata == {
32
+        "owner_type": "Agent",
33
+        "owner_path": "public-agent",
34
+    }
3235
 
3336
 
3437
 @pytest.mark.asyncio
tests/test_runtime_public_shell.pymodified
@@ -71,6 +71,8 @@ def test_create_runtime_session_copies_public_shell_state(temp_dir: Path) -> Non
7171
         prompt_format="native",
7272
         prompt_sections=["Runtime Config", "Workflow Context"],
7373
         workflow_mode="execute",
74
+        runtime_owner_type="RuntimeHandle",
75
+        runtime_owner_path="runtime-handle",
7476
         rotate_after_bytes=handle.config.session_rotate_after_bytes,
7577
         auto_compaction_input_tokens_threshold=(
7678
             handle.config.session_auto_compaction_input_tokens_threshold
@@ -83,6 +85,8 @@ def test_create_runtime_session_copies_public_shell_state(temp_dir: Path) -> Non
8385
     assert session.permission_mode == handle.active_permission_mode
8486
     assert session.permission_prompting_enabled is handle.permission_policy.prompting_enabled
8587
     assert session.permission_rule_counts == handle.permission_policy.rule_counts()
88
+    assert session.runtime_owner_type == "RuntimeHandle"
89
+    assert session.runtime_owner_path == "runtime-handle"
8690
     assert session.prompt_format == "native"
8791
     assert session.prompt_sections == ["Runtime Config", "Workflow Context"]
8892
 
@@ -374,6 +378,8 @@ def test_create_runtime_session_install_builds_restored_shell_state(
374378
         prompt_format="native",
375379
         prompt_sections=["Runtime Config", "Workflow Context"],
376380
         workflow_mode="execute",
381
+        runtime_owner_type="RuntimeHandle",
382
+        runtime_owner_path="runtime-handle",
377383
         rotate_after_bytes=handle.config.session_rotate_after_bytes,
378384
         auto_compaction_input_tokens_threshold=(
379385
             handle.config.session_auto_compaction_input_tokens_threshold
@@ -402,6 +408,8 @@ def test_apply_runtime_session_install_updates_owner_shell_state(
402408
         prompt_format="native",
403409
         prompt_sections=["Runtime Config", "Workflow Context"],
404410
         workflow_mode="plan",
411
+        runtime_owner_type="Agent",
412
+        runtime_owner_path="public-agent",
405413
         rotate_after_bytes=handle.config.session_rotate_after_bytes,
406414
         auto_compaction_input_tokens_threshold=(
407415
             handle.config.session_auto_compaction_input_tokens_threshold
@@ -423,6 +431,8 @@ def test_apply_runtime_session_install_updates_owner_shell_state(
423431
     assert handle.active_permission_mode == "prompt"
424432
     assert handle.prompt_format == "native"
425433
     assert handle.prompt_sections == ["Runtime Config", "Workflow Context"]
434
+    assert handle.session.runtime_owner_type == "RuntimeHandle"
435
+    assert handle.session.runtime_owner_path == "runtime-handle"
426436
 
427437
 
428438
 def test_build_fresh_runtime_session_install_uses_current_owner_shell_state(
tests/test_session_state.pymodified
@@ -12,6 +12,7 @@ from loader.llm.base import CompletionResponse, Message, Role, ToolCall
1212
 from loader.runtime.completion_trace import CompletionTraceEntry
1313
 from loader.runtime.evidence_provenance import EvidenceProvenance
1414
 from loader.runtime.prompt_history import PromptSnapshot
15
+from loader.runtime.runtime_handle import RuntimeHandle
1516
 from loader.runtime.session import ConversationSession
1617
 from loader.runtime.workflow_ledger import WorkflowLedger, WorkflowLedgerItem
1718
 from loader.runtime.workflow_policy import WorkflowTimelineEntry
@@ -173,6 +174,7 @@ def test_session_persists_permission_policy_metadata(temp_dir: Path) -> None:
173174
 
174175
     session.update_runtime_state(
175176
         current_task="Inspect permission history",
177
+        runtime_owner_type="RuntimeHandle",
176178
         permission_mode="allow",
177179
         permission_prompting_enabled=True,
178180
         permission_rule_counts={"allow": 2, "deny": 1, "ask": 4},
@@ -237,6 +239,8 @@ def test_session_persists_permission_policy_metadata(temp_dir: Path) -> None:
237239
     assert reloaded.permission_rules_source == str(
238240
         temp_dir / ".loader" / "permission-rules.json"
239241
     )
242
+    assert reloaded.runtime_owner_type == "RuntimeHandle"
243
+    assert reloaded.runtime_owner_path == "runtime-handle"
240244
     assert reloaded.prompt_format == "native"
241245
     assert reloaded.prompt_sections == [
242246
         "Runtime Config",
@@ -274,6 +278,35 @@ def test_session_persists_permission_policy_metadata(temp_dir: Path) -> None:
274278
     ]
275279
 
276280
 
281
+def test_resume_session_updates_runtime_owner_metadata(temp_dir: Path) -> None:
282
+    agent = Agent(
283
+        backend=ScriptedBackend(),
284
+        config=AgentConfig(auto_context=False, stream=False),
285
+        project_root=temp_dir,
286
+    )
287
+    agent.session.persist()
288
+    session_id = agent.session.session_id
289
+
290
+    handle = RuntimeHandle(
291
+        backend=ScriptedBackend(),
292
+        config=AgentConfig(auto_context=False, stream=False),
293
+        project_root=temp_dir,
294
+    )
295
+
296
+    assert handle.resume_session(session_id) is True
297
+
298
+    reloaded = ConversationSession.load(
299
+        project_root=temp_dir,
300
+        system_message_factory=_dummy_system,
301
+        few_shot_factory=_dummy_few_shots,
302
+        session_id=session_id,
303
+    )
304
+
305
+    assert reloaded is not None
306
+    assert reloaded.runtime_owner_type == "RuntimeHandle"
307
+    assert reloaded.runtime_owner_path == "runtime-handle"
308
+
309
+
277310
 def test_session_prefers_canonical_workflow_timeline_for_completion_trace(
278311
     temp_dir: Path,
279312
 ) -> None: