tenseleyflow/loader / 484a89a

Browse files

Prioritize missing outputs over text loops

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
484a89aa9c97e03c8ffe2eaa1d09ef37bfb61972
Parents
ff9cfce
Tree
0462e0b

2 changed files

StatusFile+-
M src/loader/runtime/turn_completion.py 73 73
M tests/test_turn_completion.py 89 0
src/loader/runtime/turn_completion.pymodified
@@ -171,6 +171,79 @@ class TurnCompletionController:
171171
             detail="Checking completion policy",
172172
             reason_code="completion_gate",
173173
         )
174
+        progress_messages = list(getattr(self.context.session, "messages", []) or [])
175
+        progress_intent = _build_in_progress_continuation(
176
+            content=content,
177
+            dod=dod,
178
+            project_root=self.context.project_root,
179
+            messages=progress_messages,
180
+        )
181
+        if progress_intent is not None:
182
+            assistant_message = Message(role=Role.ASSISTANT, content=response_content)
183
+            self.context.session.append(assistant_message)
184
+            summary.assistant_messages.append(assistant_message)
185
+            if (
186
+                progress_intent.target is not None
187
+                and continuation_count == 0
188
+                and _confirmed_output_file_count(
189
+                    dod,
190
+                    project_root=self.context.project_root,
191
+                )
192
+                == 0
193
+                and not _recent_concrete_target_prompt(
194
+                    progress_messages,
195
+                    target=progress_intent.target,
196
+                )
197
+            ):
198
+                self._append_completion_trace_entry(
199
+                    summary=summary,
200
+                    stage="continuation_check",
201
+                    outcome="continue",
202
+                    decision_code="in_progress_transition_continue",
203
+                    decision_summary=(
204
+                        "continued to let the assistant finish the concrete next "
205
+                        "planned step without interrupting it yet"
206
+                    ),
207
+                )
208
+                self._record_completion_decision(
209
+                    summary=summary,
210
+                    decision_code="in_progress_transition_continue",
211
+                    decision_summary=(
212
+                        "continued to let the assistant finish the concrete next "
213
+                        "planned step without interrupting it yet"
214
+                    ),
215
+                )
216
+                return TurnCompletionDecision(
217
+                    action=TurnCompletionAction.CONTINUE,
218
+                    continuation_count=continuation_count + 1,
219
+                )
220
+
221
+            self.context.session.append(
222
+                Message(role=Role.USER, content=progress_intent.prompt)
223
+            )
224
+            self._append_completion_trace_entry(
225
+                summary=summary,
226
+                stage="continuation_check",
227
+                outcome="continue",
228
+                decision_code="in_progress_transition_continue",
229
+                decision_summary=(
230
+                    "continued because the assistant described the next planned step "
231
+                    "without executing it yet"
232
+                ),
233
+            )
234
+            self._record_completion_decision(
235
+                summary=summary,
236
+                decision_code="in_progress_transition_continue",
237
+                decision_summary=(
238
+                    "continued because the assistant described the next planned step "
239
+                    "without executing it yet"
240
+                ),
241
+            )
242
+            return TurnCompletionDecision(
243
+                action=TurnCompletionAction.CONTINUE,
244
+                continuation_count=continuation_count + 1,
245
+            )
246
+
174247
         text_loop_decision = await self.completion_policy.maybe_stop_for_text_loop(
175248
             content=content,
176249
             emit=emit,
@@ -269,79 +342,6 @@ class TurnCompletionController:
269342
                     finalize_reason_summary=continuation_decision.decision_summary,
270343
                 )
271344
 
272
-        progress_messages = list(getattr(self.context.session, "messages", []) or [])
273
-        progress_intent = _build_in_progress_continuation(
274
-            content=content,
275
-            dod=dod,
276
-            project_root=self.context.project_root,
277
-            messages=progress_messages,
278
-        )
279
-        if progress_intent is not None:
280
-            assistant_message = Message(role=Role.ASSISTANT, content=response_content)
281
-            self.context.session.append(assistant_message)
282
-            summary.assistant_messages.append(assistant_message)
283
-            if (
284
-                progress_intent.target is not None
285
-                and continuation_count == 0
286
-                and _confirmed_output_file_count(
287
-                    dod,
288
-                    project_root=self.context.project_root,
289
-                )
290
-                == 0
291
-                and not _recent_concrete_target_prompt(
292
-                    progress_messages,
293
-                    target=progress_intent.target,
294
-                )
295
-            ):
296
-                self._append_completion_trace_entry(
297
-                    summary=summary,
298
-                    stage="continuation_check",
299
-                    outcome="continue",
300
-                    decision_code="in_progress_transition_continue",
301
-                    decision_summary=(
302
-                        "continued to let the assistant finish the concrete next "
303
-                        "planned step without interrupting it yet"
304
-                    ),
305
-                )
306
-                self._record_completion_decision(
307
-                    summary=summary,
308
-                    decision_code="in_progress_transition_continue",
309
-                    decision_summary=(
310
-                        "continued to let the assistant finish the concrete next "
311
-                        "planned step without interrupting it yet"
312
-                    ),
313
-                )
314
-                return TurnCompletionDecision(
315
-                    action=TurnCompletionAction.CONTINUE,
316
-                    continuation_count=continuation_count + 1,
317
-                )
318
-
319
-            self.context.session.append(
320
-                Message(role=Role.USER, content=progress_intent.prompt)
321
-            )
322
-            self._append_completion_trace_entry(
323
-                summary=summary,
324
-                stage="continuation_check",
325
-                outcome="continue",
326
-                decision_code="in_progress_transition_continue",
327
-                decision_summary=(
328
-                    "continued because the assistant described the next planned step "
329
-                    "without executing it yet"
330
-                ),
331
-            )
332
-            self._record_completion_decision(
333
-                summary=summary,
334
-                decision_code="in_progress_transition_continue",
335
-                decision_summary=(
336
-                    "continued because the assistant described the next planned step "
337
-                    "without executing it yet"
338
-                ),
339
-            )
340
-            return TurnCompletionDecision(
341
-                action=TurnCompletionAction.CONTINUE,
342
-                continuation_count=continuation_count + 1,
343
-            )
344
-
345345
         final_response = self.completion_policy.finalize_response_text(
346346
             content=content,
347347
             actions_taken=actions_taken,
tests/test_turn_completion.pymodified
@@ -528,6 +528,95 @@ async def test_turn_completion_interrupts_repeated_concrete_progress_narration(
528528
     assert "02-installation.html" in agent.session.messages[-1].content
529529
 
530530
 
531
+@pytest.mark.asyncio
532
+async def test_turn_completion_prioritizes_missing_artifact_continuation_over_text_loop(
533
+    temp_dir: Path,
534
+) -> None:
535
+    backend = ScriptedBackend()
536
+    config = non_streaming_config()
537
+    config.reasoning.completion_check = False
538
+    agent = Agent(
539
+        backend=backend,
540
+        config=config,
541
+        project_root=temp_dir,
542
+    )
543
+    runtime = ConversationRuntime(agent)
544
+    events = []
545
+
546
+    async def capture(event) -> None:
547
+        events.append(event)
548
+
549
+    prepared = await runtime.turn_preparation.prepare(
550
+        task=(
551
+            "Create a multi-file nginx guide under ~/Loader/guides/nginx "
552
+            "with an index and chapter files."
553
+        ),
554
+        emit=capture,
555
+        requested_mode="execute",
556
+        original_task=None,
557
+        on_user_question=None,
558
+    )
559
+    await runtime.phase_tracker.enter(
560
+        TurnPhase.ASSISTANT,
561
+        capture,
562
+        detail="Requesting assistant response",
563
+        reason_code="request_assistant_response",
564
+    )
565
+
566
+    implementation_plan = temp_dir / "implementation.md"
567
+    implementation_plan.write_text(
568
+        "# Implementation Plan\n\n"
569
+        "## File Changes\n\n"
570
+        "1. Create main index.html file:\n"
571
+        f"   - `{temp_dir / 'index.html'}`\n\n"
572
+        "2. Create chapter files:\n"
573
+        f"   - `{temp_dir / 'chapters' / '01-introduction.html'}`\n"
574
+        f"   - `{temp_dir / 'chapters' / '02-installation.html'}`\n"
575
+    )
576
+    chapters_dir = temp_dir / "chapters"
577
+    chapters_dir.mkdir()
578
+    (temp_dir / "index.html").write_text("<h1>NGINX Guide</h1>\n")
579
+    (chapters_dir / "01-introduction.html").write_text("<h1>Intro</h1>\n")
580
+
581
+    prepared.definition_of_done.implementation_plan = str(implementation_plan)
582
+    prepared.definition_of_done.mutating_actions.append("write")
583
+    prepared.definition_of_done.touched_files.extend(
584
+        [
585
+            str(temp_dir / "index.html"),
586
+            str(chapters_dir / "01-introduction.html"),
587
+        ]
588
+    )
589
+    prepared.definition_of_done.pending_items.append("Create chapter files for nginx guide")
590
+
591
+    content = "Let me continue creating the remaining chapter files for the nginx guide:"
592
+    runtime.context.safeguards.record_response(content)
593
+    runtime.context.safeguards.record_response(content)
594
+
595
+    decision = await runtime.turn_completion.handle_text_response(
596
+        content=content,
597
+        response_content=content,
598
+        task=prepared.task,
599
+        effective_task=prepared.effective_task,
600
+        iterations=1,
601
+        max_iterations=agent.config.max_iterations,
602
+        actions_taken=[],
603
+        continuation_count=2,
604
+        dod=prepared.definition_of_done,
605
+        emit=capture,
606
+        summary=prepared.summary,
607
+        executor=prepared.executor,
608
+        rollback_plan=prepared.rollback_plan,
609
+    )
610
+
611
+    assert decision.action == TurnCompletionAction.CONTINUE
612
+    assert prepared.summary.completion_decision_code == "in_progress_transition_continue"
613
+    assert agent.session.messages[-1].role.value == "user"
614
+    assert agent.session.messages[-1].content.startswith("[CONTINUE CURRENT STEP]")
615
+    assert "02-installation.html" in agent.session.messages[-1].content
616
+    assert not prepared.summary.final_response
617
+    assert not any(event.type == "error" and "Text loop detected" in event.content for event in events)
618
+
619
+
531620
 @pytest.mark.asyncio
532621
 async def test_turn_completion_interrupts_first_narration_after_concrete_target_prompt(
533622
     temp_dir: Path,