tenseleyflow/loader / c6e1cc1

Browse files

Continue in-progress chapter cues

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c6e1cc11f1747755b7de29b1a4073a5cefcff3ee
Parents
6eb402b
Tree
9a0f0f6

2 changed files

StatusFile+-
M src/loader/runtime/turn_completion.py 207 1
M tests/test_turn_completion.py 89 0
src/loader/runtime/turn_completion.pymodified
@@ -5,11 +5,17 @@ from __future__ import annotations
55
 from collections.abc import Awaitable, Callable
66
 from dataclasses import dataclass
77
 from enum import StrEnum
8
+from pathlib import Path
89
 
910
 from ..llm.base import Message, Role
1011
 from .completion_policy import CompletionPolicy
1112
 from .context import RuntimeContext
12
-from .dod import DefinitionOfDone
13
+from .dod import (
14
+    DefinitionOfDone,
15
+    collect_planned_artifact_targets,
16
+    infer_next_output_file,
17
+    planned_artifact_target_satisfied,
18
+)
1319
 from .events import AgentEvent, TurnSummary
1420
 from .evidence_provenance import EvidenceProvenance
1521
 from .executor import ToolExecutor
@@ -22,9 +28,39 @@ from .policy_timeline import (
2228
 from .repair import ResponseRepairer
2329
 from .rollback import RollbackPlan
2430
 from .verification_observations import VerificationObservation
31
+from .workflow import (
32
+    effective_pending_todo_items,
33
+    infer_pending_todo_output_target,
34
+    preferred_pending_todo_item,
35
+)
2536
 
2637
 EventSink = Callable[[AgentEvent], Awaitable[None]]
2738
 
39
+_SPECIAL_DOD_ITEMS = {
40
+    "Complete the requested work",
41
+    "Collect verification evidence",
42
+}
43
+_PROGRESS_INTENT_HINTS = (
44
+    "i'll ",
45
+    "i will ",
46
+    "i am going to ",
47
+    "i'm going to ",
48
+    "let me ",
49
+    "now i'll ",
50
+    "next i'll ",
51
+    "continue by ",
52
+    "continue with ",
53
+)
54
+_COMPLETION_HINTS = (
55
+    "done",
56
+    "completed",
57
+    "finished",
58
+    "all set",
59
+    "verified",
60
+    "successfully completed",
61
+    "everything is done",
62
+)
63
+
2864
 
2965
 class TurnCompletionAction(StrEnum):
3066
     """What the runtime should do after evaluating one no-tool text response."""
@@ -225,6 +261,42 @@ class TurnCompletionController:
225261
                     finalize_reason_summary=continuation_decision.decision_summary,
226262
                 )
227263
 
264
+        progress_intent_prompt = _build_in_progress_continuation_prompt(
265
+            content=content,
266
+            dod=dod,
267
+            project_root=self.context.project_root,
268
+            messages=list(getattr(self.context.session, "messages", []) or []),
269
+        )
270
+        if progress_intent_prompt:
271
+            assistant_message = Message(role=Role.ASSISTANT, content=response_content)
272
+            self.context.session.append(assistant_message)
273
+            summary.assistant_messages.append(assistant_message)
274
+            self.context.session.append(
275
+                Message(role=Role.USER, content=progress_intent_prompt)
276
+            )
277
+            self._append_completion_trace_entry(
278
+                summary=summary,
279
+                stage="continuation_check",
280
+                outcome="continue",
281
+                decision_code="in_progress_transition_continue",
282
+                decision_summary=(
283
+                    "continued because the assistant described the next planned step "
284
+                    "without executing it yet"
285
+                ),
286
+            )
287
+            self._record_completion_decision(
288
+                summary=summary,
289
+                decision_code="in_progress_transition_continue",
290
+                decision_summary=(
291
+                    "continued because the assistant described the next planned step "
292
+                    "without executing it yet"
293
+                ),
294
+            )
295
+            return TurnCompletionDecision(
296
+                action=TurnCompletionAction.CONTINUE,
297
+                continuation_count=continuation_count + 1,
298
+            )
299
+
228300
         final_response = self.completion_policy.finalize_response_text(
229301
             content=content,
230302
             actions_taken=actions_taken,
@@ -281,3 +353,137 @@ class TurnCompletionController:
281353
             action=TurnCompletionAction.COMPLETE,
282354
             continuation_count=continuation_count,
283355
         )
356
+
357
+
358
+def _build_in_progress_continuation_prompt(
359
+    *,
360
+    content: str,
361
+    dod: DefinitionOfDone,
362
+    project_root: Path,
363
+    messages: list[object],
364
+) -> str | None:
365
+    if not _looks_like_progress_intent(content):
366
+        return None
367
+
368
+    missing_artifact = _next_missing_planned_artifact(
369
+        dod,
370
+        project_root=project_root,
371
+        messages=messages,
372
+    )
373
+    next_pending = preferred_pending_todo_item(
374
+        dod,
375
+        project_root=project_root,
376
+        missing_artifact=missing_artifact,
377
+    )
378
+    if not next_pending and missing_artifact is None:
379
+        return None
380
+
381
+    target = _preferred_progress_target(
382
+        dod,
383
+        next_pending=next_pending,
384
+        missing_artifact=missing_artifact,
385
+        project_root=project_root,
386
+        messages=messages,
387
+    )
388
+    if target is not None:
389
+        return (
390
+            "[CONTINUE CURRENT STEP]\n"
391
+            "You just described the next planned step, but the concrete output is not on disk yet. "
392
+            f"Respond with one concrete `write` or `edit`-style tool call that creates or updates `{target}` now. "
393
+            "Do not summarize, verify, or restart discovery first."
394
+        )
395
+
396
+    if next_pending:
397
+        return (
398
+            "[CONTINUE CURRENT STEP]\n"
399
+            "You just described the next planned step, but it has not been executed yet. "
400
+            f"Continue with `{next_pending}` now by emitting one concrete tool call instead of another narration, summary, or verification claim."
401
+        )
402
+    return None
403
+
404
+
405
+def _looks_like_progress_intent(content: str) -> bool:
406
+    text = content.lower().strip()
407
+    if not text or "?" in text:
408
+        return False
409
+    if any(marker in text for marker in _COMPLETION_HINTS):
410
+        return False
411
+    return any(marker in text for marker in _PROGRESS_INTENT_HINTS)
412
+
413
+
414
+def _next_missing_planned_artifact(
415
+    dod: DefinitionOfDone,
416
+    *,
417
+    project_root: Path,
418
+    messages: list[object],
419
+) -> tuple[Path, bool] | None:
420
+    for target, expect_directory in collect_planned_artifact_targets(
421
+        dod,
422
+        project_root=project_root,
423
+        max_paths=12,
424
+    ):
425
+        if not planned_artifact_target_satisfied(
426
+            dod,
427
+            target=target,
428
+            expect_directory=expect_directory,
429
+            project_root=project_root,
430
+        ):
431
+            return target, expect_directory
432
+
433
+    for target, expect_directory in collect_planned_artifact_targets(
434
+        dod,
435
+        project_root=project_root,
436
+        max_paths=12,
437
+    ):
438
+        if not expect_directory or not target.is_dir():
439
+            continue
440
+        next_output_file, _ = infer_next_output_file(
441
+            target=target,
442
+            project_root=project_root,
443
+            messages=list(messages or []),
444
+        )
445
+        if next_output_file is not None and not next_output_file.exists():
446
+            return next_output_file, False
447
+    return None
448
+
449
+
450
+def _preferred_progress_target(
451
+    dod: DefinitionOfDone,
452
+    *,
453
+    next_pending: str | None,
454
+    missing_artifact: tuple[Path, bool] | None,
455
+    project_root: Path,
456
+    messages: list[object],
457
+) -> Path | None:
458
+    pending_items = [
459
+        item
460
+        for item in effective_pending_todo_items(
461
+            dod,
462
+            project_root=project_root,
463
+        )
464
+        if item not in _SPECIAL_DOD_ITEMS
465
+    ]
466
+    if next_pending and next_pending in pending_items:
467
+        pending_target = infer_pending_todo_output_target(
468
+            dod,
469
+            next_pending,
470
+            project_root=project_root,
471
+        )
472
+        if pending_target is not None and not pending_target.exists():
473
+            return pending_target
474
+
475
+    if missing_artifact is None:
476
+        return None
477
+
478
+    target, expect_directory = missing_artifact
479
+    if not expect_directory:
480
+        return target
481
+
482
+    next_output_file, _ = infer_next_output_file(
483
+        target=target,
484
+        project_root=project_root,
485
+        messages=list(messages or []),
486
+    )
487
+    if next_output_file is not None:
488
+        return next_output_file
489
+    return None
tests/test_turn_completion.pymodified
@@ -283,6 +283,95 @@ async def test_turn_completion_blocks_false_completion_without_preserving_it(
283283
     assert not any(event.type == "response" for event in events)
284284
 
285285
 
286
+@pytest.mark.asyncio
287
+async def test_turn_completion_continues_progress_intent_without_dod_gate_spam(
288
+    temp_dir: Path,
289
+) -> None:
290
+    backend = ScriptedBackend()
291
+    agent = Agent(
292
+        backend=backend,
293
+        config=non_streaming_config(),
294
+        project_root=temp_dir,
295
+    )
296
+    runtime = ConversationRuntime(agent)
297
+    events = []
298
+
299
+    async def capture(event) -> None:
300
+        events.append(event)
301
+
302
+    prepared = await runtime.turn_preparation.prepare(
303
+        task=(
304
+            "Create a multi-file nginx guide under ~/Loader/guides/nginx "
305
+            "with an index and chapter files."
306
+        ),
307
+        emit=capture,
308
+        requested_mode="execute",
309
+        original_task=None,
310
+        on_user_question=None,
311
+    )
312
+    await runtime.phase_tracker.enter(
313
+        TurnPhase.ASSISTANT,
314
+        capture,
315
+        detail="Requesting assistant response",
316
+        reason_code="request_assistant_response",
317
+    )
318
+
319
+    implementation_plan = temp_dir / "implementation.md"
320
+    implementation_plan.write_text(
321
+        "# Implementation Plan\n\n"
322
+        "## File Changes\n\n"
323
+        "1. Create main index.html file:\n"
324
+        f"   - `{temp_dir / 'index.html'}`\n\n"
325
+        "2. Create chapter files:\n"
326
+        f"   - `{temp_dir / 'chapters' / '01-introduction.html'}`\n"
327
+        f"   - `{temp_dir / 'chapters' / '02-installation.html'}`\n"
328
+    )
329
+    chapters_dir = temp_dir / "chapters"
330
+    chapters_dir.mkdir()
331
+    (temp_dir / "index.html").write_text("<h1>NGINX Guide</h1>\n")
332
+    (chapters_dir / "01-introduction.html").write_text("<h1>Intro</h1>\n")
333
+
334
+    prepared.definition_of_done.implementation_plan = str(implementation_plan)
335
+    prepared.definition_of_done.mutating_actions.append("write")
336
+    prepared.definition_of_done.touched_files.extend(
337
+        [
338
+            str(temp_dir / "index.html"),
339
+            str(chapters_dir / "01-introduction.html"),
340
+        ]
341
+    )
342
+    prepared.definition_of_done.pending_items.append("Create chapter files for nginx guide")
343
+
344
+    content = "Now I'll create the second chapter file for the nginx guide."
345
+    decision = await runtime.turn_completion.handle_text_response(
346
+        content=content,
347
+        response_content=content,
348
+        task=prepared.task,
349
+        effective_task=prepared.effective_task,
350
+        iterations=1,
351
+        max_iterations=agent.config.max_iterations,
352
+        actions_taken=[],
353
+        continuation_count=0,
354
+        dod=prepared.definition_of_done,
355
+        emit=capture,
356
+        summary=prepared.summary,
357
+        executor=prepared.executor,
358
+        rollback_plan=prepared.rollback_plan,
359
+    )
360
+
361
+    assert decision.action == TurnCompletionAction.CONTINUE
362
+    assert decision.continuation_count == 1
363
+    assert prepared.summary.completion_decision_code == "in_progress_transition_continue"
364
+    assert prepared.summary.assistant_messages[-1].content == content
365
+    assert agent.session.messages[-1].role.value == "user"
366
+    assert agent.session.messages[-1].content.startswith("[CONTINUE CURRENT STEP]")
367
+    assert "02-installation.html" in agent.session.messages[-1].content
368
+    assert not any(
369
+        message.role.value == "user"
370
+        and message.content.startswith("[PLANNED ARTIFACTS STILL MISSING]")
371
+        for message in agent.session.messages
372
+    )
373
+
374
+
286375
 @pytest.mark.asyncio
287376
 async def test_turn_completion_handles_fake_tool_narration_without_reroute(
288377
     temp_dir: Path,