tenseleyflow/loader / 1b724ec

Browse files

Reset task state between top-level prompts

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
1b724ecb488beb058a0904b1ad499aa9384eb0e2
Parents
36eb700
Tree
1561ea3

8 changed files

StatusFile+-
M src/loader/agent/loop.py 5 0
M src/loader/runtime/launcher.py 17 3
M src/loader/runtime/runtime_handle.py 5 0
M src/loader/tools/workflow_tools.py 2 2
M src/loader/ui/adapter.py 1 1
M src/loader/ui/app.py 1 0
A src/loader/utils/todos.py 25 0
M tests/test_runtime_launcher.py 55 0
src/loader/agent/loop.pymodified
@@ -177,7 +177,12 @@ class Agent:
177177
 
178178
     @current_task.setter
179179
     def current_task(self, value: str | None) -> None:
180
+        if self._current_task == value:
181
+            return
180182
         self._current_task = value
183
+        self._system_message = None
184
+        if hasattr(self, "session") and self.session is not None:
185
+            self.session.update_runtime_state(current_task=value)
181186
 
182187
     @property
183188
     def active_permission_mode(self) -> str:
src/loader/runtime/launcher.pymodified
@@ -3,6 +3,7 @@
33
 from __future__ import annotations
44
 
55
 from ..llm.base import Message, Role
6
+from ..utils.todos import active_todo_store_path, clear_active_todos
67
 from .bootstrap import (
78
     RuntimeBootstrapSource,
89
     RuntimeBootstrapView,
@@ -12,7 +13,7 @@ from .chat_lane import ConversationalTurnRunner
1213
 from .conversation import ConfirmationHandler, ConversationRuntime, EventSink, UserQuestionHandler
1314
 from .decomposition_lane import DecompositionTurnRunner
1415
 from .deliberation import should_decompose
15
-from .events import TurnSummary
16
+from .events import AgentEvent, TurnSummary
1617
 from .explore import ExploreRuntime
1718
 from .task_classification import is_conversational
1819
 from .workflow import WorkflowMode
@@ -48,8 +49,7 @@ class RuntimeLauncher:
4849
         if is_conversational(user_message):
4950
             return await self.run_conversational(user_message, emit)
5051
 
51
-        if self.source.current_task is None:
52
-            self.source.current_task = user_message
52
+        await self._begin_top_level_task(user_message, emit)
5353
 
5454
         requested_mode = self._requested_workflow_mode(use_plan)
5555
 
@@ -167,6 +167,20 @@ class RuntimeLauncher:
167167
             return WorkflowMode.PLAN.value
168168
         return None
169169
 
170
+    async def _begin_top_level_task(self, user_message: str, emit: EventSink) -> None:
171
+        """Reset task-scoped state before a new top-level task starts."""
172
+
173
+        previous_task = self.source.current_task
174
+        had_active_todos = active_todo_store_path(self.source.project_root).exists()
175
+        self.source.current_task = user_message
176
+
177
+        if previous_task == user_message:
178
+            return
179
+
180
+        clear_active_todos(self.source.project_root)
181
+        if previous_task is not None or had_active_todos:
182
+            await emit(AgentEvent(type="todo_update", todo_items=[]))
183
+
170184
 
171185
 def build_runtime_launcher(source: RuntimeBootstrapSource) -> RuntimeLauncher:
172186
     """Build a public runtime launcher from the shared bootstrap source."""
src/loader/runtime/runtime_handle.pymodified
@@ -98,7 +98,12 @@ class RuntimeHandle:
9898
 
9999
     @current_task.setter
100100
     def current_task(self, value: str | None) -> None:
101
+        if self._current_task == value:
102
+            return
101103
         self._current_task = value
104
+        self._system_message = None
105
+        if hasattr(self, "session") and self.session is not None:
106
+            self.session.update_runtime_state(current_task=value)
102107
 
103108
     @property
104109
     def active_permission_mode(self) -> str:
src/loader/tools/workflow_tools.pymodified
@@ -10,6 +10,7 @@ from pathlib import Path
1010
 from typing import Any
1111
 
1212
 from ..runtime.permissions import PermissionMode
13
+from ..utils.todos import active_todo_store_path
1314
 from .base import Tool, ToolResult
1415
 
1516
 UserQuestionHandler = Callable[[str, list[str] | None], Awaitable[str]]
@@ -144,8 +145,7 @@ class TodoWriteTool(Tool):
144145
         )
145146
 
146147
     def _store_path(self) -> Path:
147
-        root = self.workspace_root or Path.cwd()
148
-        return root / ".loader" / "todos" / "active.json"
148
+        return active_todo_store_path(self.workspace_root or Path.cwd())
149149
 
150150
     def _read_existing_items(self, store_path: Path) -> list[dict[str, Any]]:
151151
         if not store_path.exists():
src/loader/ui/adapter.pymodified
@@ -435,7 +435,7 @@ class EventAdapter:
435435
                 self.app.post_message(ResponseComplete(content=event.content))
436436
 
437437
             case "todo_update":
438
-                if event.todo_items:
438
+                if event.todo_items is not None:
439439
                     self.app.post_message(TodoListUpdated(todos=event.todo_items))
440440
 
441441
             case "confirmation":
src/loader/ui/app.pymodified
@@ -1037,6 +1037,7 @@ class LoaderApp(App):
10371037
         self._streamed_content = False
10381038
         self._tool_widget_queue.clear()
10391039
         self.shell_owner.clear_history()
1040
+        self.query_one("#todo-list", TodoListWidget).update_todos([])
10401041
         self.query_one(StatusLine).clear_definition_of_done()
10411042
         self.query_one(StatusLine).update_session_id(self.shell_owner.session.session_id)
10421043
         self.query_one(StatusLine).update_runtime_owner(
src/loader/utils/todos.pyadded
@@ -0,0 +1,25 @@
1
+"""Helpers for the active Loader todo store."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+
7
+
8
+def active_todo_store_path(workspace_root: Path | str | None) -> Path:
9
+    """Return the path to the active todo store for a workspace."""
10
+
11
+    root = (
12
+        Path(workspace_root).expanduser().resolve()
13
+        if workspace_root is not None
14
+        else Path.cwd()
15
+    )
16
+    return root / ".loader" / "todos" / "active.json"
17
+
18
+
19
+def clear_active_todos(workspace_root: Path | str | None) -> Path:
20
+    """Delete the active todo store if it exists."""
21
+
22
+    path = active_todo_store_path(workspace_root)
23
+    if path.exists():
24
+        path.unlink()
25
+    return path
tests/test_runtime_launcher.pymodified
@@ -10,7 +10,9 @@ from loader.agent.loop import Agent, AgentConfig, ReasoningConfig
1010
 from loader.llm.base import CompletionResponse, StreamChunk
1111
 from loader.runtime.bootstrap import RuntimeBootstrapView
1212
 from loader.runtime.launcher import RuntimeLauncher, build_runtime_launcher
13
+from loader.runtime.public_shell import get_runtime_shell_system_message
1314
 from loader.runtime.runtime_handle import RuntimeHandle
15
+from loader.utils.todos import active_todo_store_path
1416
 from tests.helpers.runtime_harness import ScriptedBackend
1517
 
1618
 
@@ -256,3 +258,56 @@ async def test_runtime_launcher_routes_user_message_through_decomposition_lane(
256258
         }
257259
     ]
258260
     assert events == []
261
+
262
+
263
+@pytest.mark.asyncio
264
+async def test_runtime_launcher_resets_task_scoped_state_for_new_top_level_prompt(
265
+    temp_dir: Path,
266
+) -> None:
267
+    backend = ScriptedBackend(
268
+        completions=[CompletionResponse(content="Penguins page shipped.")]
269
+    )
270
+    handle = RuntimeHandle(
271
+        backend=backend,
272
+        config=AgentConfig(
273
+            auto_context=False,
274
+            stream=False,
275
+            reasoning=ReasoningConfig(completion_check=False),
276
+        ),
277
+        project_root=temp_dir,
278
+    )
279
+    handle.current_task = "Create a collection of animal pages."
280
+    cached_prompt = get_runtime_shell_system_message(handle)
281
+    assert "Create a collection of animal pages." in cached_prompt.content
282
+
283
+    todo_store = active_todo_store_path(temp_dir)
284
+    todo_store.parent.mkdir(parents=True, exist_ok=True)
285
+    todo_store.write_text(
286
+        '[{"content": "Build cat page", "active_form": "Building cat page", "status": "pending"}]'
287
+    )
288
+
289
+    launcher = build_runtime_launcher(handle)
290
+    events = []
291
+
292
+    async def emit(event) -> None:
293
+        events.append(event)
294
+
295
+    response = await launcher.run_user_message(
296
+        "Generate penguins.html and penguins.css for the new page.",
297
+        emit,
298
+        use_plan=False,
299
+    )
300
+
301
+    assert response == "Penguins page shipped."
302
+    assert handle.current_task == "Generate penguins.html and penguins.css for the new page."
303
+    assert not todo_store.exists()
304
+    assert any(
305
+        event.type == "todo_update" and event.todo_items == []
306
+        for event in events
307
+    )
308
+    invocation = backend.invocations[-1]
309
+    assert (
310
+        "Current task: Generate penguins.html and penguins.css for the new page."
311
+        in invocation.messages[0].content
312
+    )
313
+    assert "Current task: Create a collection of animal pages." not in invocation.messages[0].content