Extract turn prelude control from conversation runtime
- SHA
feb234f4adefbfa4bd5b866dea4193c7c8e51f14- Parents
-
eda0bc4 - Tree
fcdf89f
feb234f
feb234f4adefbfa4bd5b866dea4193c7c8e51f14eda0bc4
fcdf89f| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/conversation.py
|
13 | 38 |
| A |
src/loader/runtime/turn_preamble.py
|
104 | 0 |
src/loader/runtime/conversation.pymodified@@ -20,6 +20,7 @@ from .tool_batches import ToolBatchRunner | ||
| 20 | 20 | from .tracing import RuntimeTracer |
| 21 | 21 | from .turn_completion import TurnCompletionController |
| 22 | 22 | from .turn_iteration import TurnIterationAction, TurnIterationController |
| 23 | +from .turn_preamble import TurnPreludeController | |
| 23 | 24 | from .turn_preparation import TurnPreparationController |
| 24 | 25 | from .workflow import ( |
| 25 | 26 | ModeDecision, |
@@ -105,6 +106,11 @@ class ConversationRuntime: | ||
| 105 | 106 | append_timeline=self._append_workflow_timeline_from_decision, |
| 106 | 107 | append_execute_bridge=self._maybe_append_execute_bridge, |
| 107 | 108 | ) |
| 109 | + self.turn_preamble = TurnPreludeController( | |
| 110 | + agent, | |
| 111 | + tracer=self.tracer, | |
| 112 | + workflow_recovery=self.workflow_recovery, | |
| 113 | + ) | |
| 108 | 114 | |
| 109 | 115 | async def run_turn( |
| 110 | 116 | self, |
@@ -143,51 +149,20 @@ class ConversationRuntime: | ||
| 143 | 149 | |
| 144 | 150 | while iterations < self.agent.config.max_iterations: |
| 145 | 151 | iterations += 1 |
| 146 | - summary.iterations = iterations | |
| 147 | - self.tracer.record("turn.iteration_started", iteration=iterations) | |
| 148 | - | |
| 149 | - if iterations == 1 and len(self.agent.messages) == 1: | |
| 150 | - task_lower = task.lower() | |
| 151 | - action_keywords = [ | |
| 152 | - "create", | |
| 153 | - "write", | |
| 154 | - "make", | |
| 155 | - "run", | |
| 156 | - "execute", | |
| 157 | - "build", | |
| 158 | - "install", | |
| 159 | - "delete", | |
| 160 | - "remove", | |
| 161 | - "add", | |
| 162 | - "edit", | |
| 163 | - "modify", | |
| 164 | - "update", | |
| 165 | - "fix", | |
| 166 | - ] | |
| 167 | - if any(keyword in task_lower for keyword in action_keywords): | |
| 168 | - self.agent.session.append(Message(role=Role.ASSISTANT, content="[")) | |
| 169 | - | |
| 170 | - steering_messages = self.agent._drain_steering_queue() | |
| 171 | - for steering_message in steering_messages: | |
| 172 | - await emit(AgentEvent(type="steering", content=steering_message)) | |
| 173 | - self.agent.session.append( | |
| 174 | - Message( | |
| 175 | - role=Role.USER, | |
| 176 | - content=f"[USER INTERRUPTION]: {steering_message}", | |
| 177 | - ) | |
| 178 | - ) | |
| 179 | - | |
| 180 | - if await self.workflow_recovery.maybe_refresh_plan_for_drift( | |
| 181 | - task=original_task or task, | |
| 152 | + assert self.executor is not None | |
| 153 | + prelude_decision = await self.turn_preamble.prepare_iteration( | |
| 154 | + task=task, | |
| 155 | + original_task=original_task, | |
| 156 | + iterations=iterations, | |
| 182 | 157 | dod=dod, |
| 183 | 158 | emit=emit, |
| 184 | 159 | summary=summary, |
| 185 | 160 | on_user_question=on_user_question, |
| 186 | 161 | executor=self.executor, |
| 187 | - ): | |
| 162 | + ) | |
| 163 | + if prelude_decision.should_continue: | |
| 188 | 164 | continue |
| 189 | 165 | |
| 190 | - assert self.executor is not None | |
| 191 | 166 | iteration_decision = await self.turn_iteration.run_iteration( |
| 192 | 167 | task=task, |
| 193 | 168 | effective_task=effective_task, |
src/loader/runtime/turn_preamble.pyadded@@ -0,0 +1,104 @@ | ||
| 1 | +"""Per-iteration bookkeeping and prelude control for conversation turns.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from collections.abc import Awaitable, Callable | |
| 6 | +from dataclasses import dataclass | |
| 7 | + | |
| 8 | +from ..llm.base import Message, Role | |
| 9 | +from .dod import DefinitionOfDone | |
| 10 | +from .events import AgentEvent, TurnSummary | |
| 11 | +from .tracing import RuntimeTracer | |
| 12 | +from .workflow_recovery import WorkflowRecoveryController | |
| 13 | + | |
| 14 | +EventSink = Callable[[AgentEvent], Awaitable[None]] | |
| 15 | +UserQuestionHandler = Callable[[str, list[str] | None], Awaitable[str]] | None | |
| 16 | + | |
| 17 | +_ACTION_KEYWORDS = ( | |
| 18 | + "create", | |
| 19 | + "write", | |
| 20 | + "make", | |
| 21 | + "run", | |
| 22 | + "execute", | |
| 23 | + "build", | |
| 24 | + "install", | |
| 25 | + "delete", | |
| 26 | + "remove", | |
| 27 | + "add", | |
| 28 | + "edit", | |
| 29 | + "modify", | |
| 30 | + "update", | |
| 31 | + "fix", | |
| 32 | +) | |
| 33 | + | |
| 34 | + | |
| 35 | +@dataclass(slots=True) | |
| 36 | +class TurnPreludeDecision: | |
| 37 | + """Outcome of per-iteration prelude handling.""" | |
| 38 | + | |
| 39 | + should_continue: bool = False | |
| 40 | + | |
| 41 | + | |
| 42 | +class TurnPreludeController: | |
| 43 | + """Own iteration bookkeeping, steering drainage, and drift gating.""" | |
| 44 | + | |
| 45 | + def __init__( | |
| 46 | + self, | |
| 47 | + agent, | |
| 48 | + *, | |
| 49 | + tracer: RuntimeTracer, | |
| 50 | + workflow_recovery: WorkflowRecoveryController, | |
| 51 | + ) -> None: | |
| 52 | + self.agent = agent | |
| 53 | + self.tracer = tracer | |
| 54 | + self.workflow_recovery = workflow_recovery | |
| 55 | + | |
| 56 | + async def prepare_iteration( | |
| 57 | + self, | |
| 58 | + *, | |
| 59 | + task: str, | |
| 60 | + original_task: str | None, | |
| 61 | + iterations: int, | |
| 62 | + dod: DefinitionOfDone, | |
| 63 | + emit: EventSink, | |
| 64 | + summary: TurnSummary, | |
| 65 | + on_user_question: UserQuestionHandler, | |
| 66 | + executor, | |
| 67 | + ) -> TurnPreludeDecision: | |
| 68 | + """Handle iteration bookkeeping before requesting the assistant turn.""" | |
| 69 | + | |
| 70 | + summary.iterations = iterations | |
| 71 | + self.tracer.record("turn.iteration_started", iteration=iterations) | |
| 72 | + | |
| 73 | + if self._should_seed_action_bracket(task=task, iterations=iterations): | |
| 74 | + self.agent.session.append(Message(role=Role.ASSISTANT, content="[")) | |
| 75 | + | |
| 76 | + steering_messages = self.agent._drain_steering_queue() | |
| 77 | + for steering_message in steering_messages: | |
| 78 | + await emit(AgentEvent(type="steering", content=steering_message)) | |
| 79 | + self.agent.session.append( | |
| 80 | + Message( | |
| 81 | + role=Role.USER, | |
| 82 | + content=f"[USER INTERRUPTION]: {steering_message}", | |
| 83 | + ) | |
| 84 | + ) | |
| 85 | + | |
| 86 | + if await self.workflow_recovery.maybe_refresh_plan_for_drift( | |
| 87 | + task=original_task or task, | |
| 88 | + dod=dod, | |
| 89 | + emit=emit, | |
| 90 | + summary=summary, | |
| 91 | + on_user_question=on_user_question, | |
| 92 | + executor=executor, | |
| 93 | + ): | |
| 94 | + return TurnPreludeDecision(should_continue=True) | |
| 95 | + | |
| 96 | + return TurnPreludeDecision() | |
| 97 | + | |
| 98 | + def _should_seed_action_bracket(self, *, task: str, iterations: int) -> bool: | |
| 99 | + """Return whether to preserve the legacy action-seed hint.""" | |
| 100 | + | |
| 101 | + if iterations != 1 or len(self.agent.messages) != 1: | |
| 102 | + return False | |
| 103 | + task_lower = task.lower() | |
| 104 | + return any(keyword in task_lower for keyword in _ACTION_KEYWORDS) | |