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:
177
 
177
 
178
     @current_task.setter
178
     @current_task.setter
179
     def current_task(self, value: str | None) -> None:
179
     def current_task(self, value: str | None) -> None:
180
+        if self._current_task == value:
181
+            return
180
         self._current_task = value
182
         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)
181
 
186
 
182
     @property
187
     @property
183
     def active_permission_mode(self) -> str:
188
     def active_permission_mode(self) -> str:
src/loader/runtime/launcher.pymodified
@@ -3,6 +3,7 @@
3
 from __future__ import annotations
3
 from __future__ import annotations
4
 
4
 
5
 from ..llm.base import Message, Role
5
 from ..llm.base import Message, Role
6
+from ..utils.todos import active_todo_store_path, clear_active_todos
6
 from .bootstrap import (
7
 from .bootstrap import (
7
     RuntimeBootstrapSource,
8
     RuntimeBootstrapSource,
8
     RuntimeBootstrapView,
9
     RuntimeBootstrapView,
@@ -12,7 +13,7 @@ from .chat_lane import ConversationalTurnRunner
12
 from .conversation import ConfirmationHandler, ConversationRuntime, EventSink, UserQuestionHandler
13
 from .conversation import ConfirmationHandler, ConversationRuntime, EventSink, UserQuestionHandler
13
 from .decomposition_lane import DecompositionTurnRunner
14
 from .decomposition_lane import DecompositionTurnRunner
14
 from .deliberation import should_decompose
15
 from .deliberation import should_decompose
15
-from .events import TurnSummary
16
+from .events import AgentEvent, TurnSummary
16
 from .explore import ExploreRuntime
17
 from .explore import ExploreRuntime
17
 from .task_classification import is_conversational
18
 from .task_classification import is_conversational
18
 from .workflow import WorkflowMode
19
 from .workflow import WorkflowMode
@@ -48,8 +49,7 @@ class RuntimeLauncher:
48
         if is_conversational(user_message):
49
         if is_conversational(user_message):
49
             return await self.run_conversational(user_message, emit)
50
             return await self.run_conversational(user_message, emit)
50
 
51
 
51
-        if self.source.current_task is None:
52
+        await self._begin_top_level_task(user_message, emit)
52
-            self.source.current_task = user_message
53
 
53
 
54
         requested_mode = self._requested_workflow_mode(use_plan)
54
         requested_mode = self._requested_workflow_mode(use_plan)
55
 
55
 
@@ -167,6 +167,20 @@ class RuntimeLauncher:
167
             return WorkflowMode.PLAN.value
167
             return WorkflowMode.PLAN.value
168
         return None
168
         return None
169
 
169
 
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
+
170
 
184
 
171
 def build_runtime_launcher(source: RuntimeBootstrapSource) -> RuntimeLauncher:
185
 def build_runtime_launcher(source: RuntimeBootstrapSource) -> RuntimeLauncher:
172
     """Build a public runtime launcher from the shared bootstrap source."""
186
     """Build a public runtime launcher from the shared bootstrap source."""
src/loader/runtime/runtime_handle.pymodified
@@ -98,7 +98,12 @@ class RuntimeHandle:
98
 
98
 
99
     @current_task.setter
99
     @current_task.setter
100
     def current_task(self, value: str | None) -> None:
100
     def current_task(self, value: str | None) -> None:
101
+        if self._current_task == value:
102
+            return
101
         self._current_task = value
103
         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)
102
 
107
 
103
     @property
108
     @property
104
     def active_permission_mode(self) -> str:
109
     def active_permission_mode(self) -> str:
src/loader/tools/workflow_tools.pymodified
@@ -10,6 +10,7 @@ from pathlib import Path
10
 from typing import Any
10
 from typing import Any
11
 
11
 
12
 from ..runtime.permissions import PermissionMode
12
 from ..runtime.permissions import PermissionMode
13
+from ..utils.todos import active_todo_store_path
13
 from .base import Tool, ToolResult
14
 from .base import Tool, ToolResult
14
 
15
 
15
 UserQuestionHandler = Callable[[str, list[str] | None], Awaitable[str]]
16
 UserQuestionHandler = Callable[[str, list[str] | None], Awaitable[str]]
@@ -144,8 +145,7 @@ class TodoWriteTool(Tool):
144
         )
145
         )
145
 
146
 
146
     def _store_path(self) -> Path:
147
     def _store_path(self) -> Path:
147
-        root = self.workspace_root or Path.cwd()
148
+        return active_todo_store_path(self.workspace_root or Path.cwd())
148
-        return root / ".loader" / "todos" / "active.json"
149
 
149
 
150
     def _read_existing_items(self, store_path: Path) -> list[dict[str, Any]]:
150
     def _read_existing_items(self, store_path: Path) -> list[dict[str, Any]]:
151
         if not store_path.exists():
151
         if not store_path.exists():
src/loader/ui/adapter.pymodified
@@ -435,7 +435,7 @@ class EventAdapter:
435
                 self.app.post_message(ResponseComplete(content=event.content))
435
                 self.app.post_message(ResponseComplete(content=event.content))
436
 
436
 
437
             case "todo_update":
437
             case "todo_update":
438
-                if event.todo_items:
438
+                if event.todo_items is not None:
439
                     self.app.post_message(TodoListUpdated(todos=event.todo_items))
439
                     self.app.post_message(TodoListUpdated(todos=event.todo_items))
440
 
440
 
441
             case "confirmation":
441
             case "confirmation":
src/loader/ui/app.pymodified
@@ -1037,6 +1037,7 @@ class LoaderApp(App):
1037
         self._streamed_content = False
1037
         self._streamed_content = False
1038
         self._tool_widget_queue.clear()
1038
         self._tool_widget_queue.clear()
1039
         self.shell_owner.clear_history()
1039
         self.shell_owner.clear_history()
1040
+        self.query_one("#todo-list", TodoListWidget).update_todos([])
1040
         self.query_one(StatusLine).clear_definition_of_done()
1041
         self.query_one(StatusLine).clear_definition_of_done()
1041
         self.query_one(StatusLine).update_session_id(self.shell_owner.session.session_id)
1042
         self.query_one(StatusLine).update_session_id(self.shell_owner.session.session_id)
1042
         self.query_one(StatusLine).update_runtime_owner(
1043
         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
10
 from loader.llm.base import CompletionResponse, StreamChunk
10
 from loader.llm.base import CompletionResponse, StreamChunk
11
 from loader.runtime.bootstrap import RuntimeBootstrapView
11
 from loader.runtime.bootstrap import RuntimeBootstrapView
12
 from loader.runtime.launcher import RuntimeLauncher, build_runtime_launcher
12
 from loader.runtime.launcher import RuntimeLauncher, build_runtime_launcher
13
+from loader.runtime.public_shell import get_runtime_shell_system_message
13
 from loader.runtime.runtime_handle import RuntimeHandle
14
 from loader.runtime.runtime_handle import RuntimeHandle
15
+from loader.utils.todos import active_todo_store_path
14
 from tests.helpers.runtime_harness import ScriptedBackend
16
 from tests.helpers.runtime_harness import ScriptedBackend
15
 
17
 
16
 
18
 
@@ -256,3 +258,56 @@ async def test_runtime_launcher_routes_user_message_through_decomposition_lane(
256
         }
258
         }
257
     ]
259
     ]
258
     assert events == []
260
     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