Add typed workflow signal extraction
- SHA
82a0ca7faa669e27386a80e93191ea14ec673abd- Parents
-
1aaccad - Tree
a0cc95f
82a0ca7
82a0ca7faa669e27386a80e93191ea14ec673abd1aaccad
a0cc95f| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/conversation.py
|
35 | 22 |
| M |
src/loader/runtime/workflow.py
|
3 | 0 |
| M |
src/loader/runtime/workflow_policy.py
|
71 | 25 |
| A |
src/loader/runtime/workflow_signals.py
|
221 | 0 |
| M |
tests/test_workflow_policy.py
|
20 | 0 |
| A |
tests/test_workflow_signals.py
|
55 | 0 |
src/loader/runtime/conversation.pymodified@@ -35,6 +35,7 @@ from .workflow import ( | ||
| 35 | 35 | WorkflowDecisionKind, |
| 36 | 36 | WorkflowMode, |
| 37 | 37 | WorkflowPolicy, |
| 38 | + WorkflowSignalExtractor, | |
| 38 | 39 | WorkflowTimelineEntry, |
| 39 | 40 | WorkflowTimelineEntryKind, |
| 40 | 41 | build_execute_bridge, |
@@ -54,7 +55,8 @@ class ConversationRuntime: | ||
| 54 | 55 | self.tracer = RuntimeTracer() |
| 55 | 56 | self.executor: ToolExecutor | None = None |
| 56 | 57 | self.dod_store = DefinitionOfDoneStore(agent.project_root) |
| 57 | - self.workflow_policy = WorkflowPolicy() | |
| 58 | + self.workflow_signals = WorkflowSignalExtractor() | |
| 59 | + self.workflow_policy = WorkflowPolicy(self.workflow_signals) | |
| 58 | 60 | self.artifact_store = WorkflowArtifactStore(agent.project_root) |
| 59 | 61 | self.turn_requester = AssistantTurnRequester(agent, self.tracer) |
| 60 | 62 | self.tool_batches = ToolBatchRunner(agent, self.dod_store) |
@@ -488,12 +490,15 @@ class ConversationRuntime: | ||
| 488 | 490 | requested_mode: str | None, |
| 489 | 491 | ) -> str: |
| 490 | 492 | requested = WorkflowMode.from_str(requested_mode) |
| 491 | - decision = self.workflow_policy.route( | |
| 492 | - task, | |
| 493 | - requested_mode=requested, | |
| 494 | - has_brief=self._artifact_exists(dod.clarify_brief), | |
| 495 | - has_plan=self._artifact_exists(dod.implementation_plan) | |
| 496 | - and self._artifact_exists(dod.verification_plan), | |
| 493 | + decision = self.workflow_policy.route_from_signals( | |
| 494 | + self.workflow_signals.extract_route_signals( | |
| 495 | + task, | |
| 496 | + requested_mode=requested.value if requested is not None else None, | |
| 497 | + has_brief=self._artifact_exists(dod.clarify_brief), | |
| 498 | + has_plan=self._artifact_exists(dod.implementation_plan) | |
| 499 | + and self._artifact_exists(dod.verification_plan), | |
| 500 | + timeline=self.agent.session.workflow_timeline, | |
| 501 | + ) | |
| 497 | 502 | ) |
| 498 | 503 | await self._set_workflow_mode( |
| 499 | 504 | decision, |
@@ -516,13 +521,16 @@ class ConversationRuntime: | ||
| 516 | 521 | summary=summary, |
| 517 | 522 | on_user_question=on_user_question, |
| 518 | 523 | ) |
| 519 | - decision = self.workflow_policy.route( | |
| 520 | - task, | |
| 521 | - has_brief=self._artifact_exists(dod.clarify_brief), | |
| 522 | - has_plan=self._artifact_exists(dod.implementation_plan) | |
| 523 | - and self._artifact_exists(dod.verification_plan), | |
| 524 | - allow_clarify=False, | |
| 525 | - unresolved_questions=clarify_review.unresolved_questions, | |
| 524 | + decision = self.workflow_policy.route_from_signals( | |
| 525 | + self.workflow_signals.extract_route_signals( | |
| 526 | + task, | |
| 527 | + has_brief=self._artifact_exists(dod.clarify_brief), | |
| 528 | + has_plan=self._artifact_exists(dod.implementation_plan) | |
| 529 | + and self._artifact_exists(dod.verification_plan), | |
| 530 | + allow_clarify=False, | |
| 531 | + unresolved_questions=clarify_review.unresolved_questions, | |
| 532 | + timeline=self.agent.session.workflow_timeline, | |
| 533 | + ) | |
| 526 | 534 | ) |
| 527 | 535 | await self._set_workflow_mode( |
| 528 | 536 | decision.with_context( |
@@ -1096,14 +1104,19 @@ class ConversationRuntime: | ||
| 1096 | 1104 | if not freshness.stale_plan: |
| 1097 | 1105 | return False |
| 1098 | 1106 | |
| 1099 | - decision = self.workflow_policy.route( | |
| 1100 | - task, | |
| 1101 | - has_brief=self._artifact_exists(dod.clarify_brief), | |
| 1102 | - has_plan=True, | |
| 1103 | - allow_clarify=False, | |
| 1104 | - stale_plan=True, | |
| 1105 | - verification_pressure=bool(dod.retry_count or dod.last_verification_result == "failed"), | |
| 1106 | - unresolved_questions=freshness.reasons, | |
| 1107 | + decision = self.workflow_policy.route_from_signals( | |
| 1108 | + self.workflow_signals.extract_route_signals( | |
| 1109 | + task, | |
| 1110 | + has_brief=self._artifact_exists(dod.clarify_brief), | |
| 1111 | + has_plan=True, | |
| 1112 | + allow_clarify=False, | |
| 1113 | + stale_plan=True, | |
| 1114 | + verification_pressure=bool( | |
| 1115 | + dod.retry_count or dod.last_verification_result == "failed" | |
| 1116 | + ), | |
| 1117 | + unresolved_questions=freshness.reasons, | |
| 1118 | + timeline=self.agent.session.workflow_timeline, | |
| 1119 | + ) | |
| 1107 | 1120 | ) |
| 1108 | 1121 | await self._set_workflow_mode( |
| 1109 | 1122 | decision, |
src/loader/runtime/workflow.pymodified@@ -19,6 +19,7 @@ from .workflow_policy import ( | ||
| 19 | 19 | WorkflowTimelineEntry, |
| 20 | 20 | WorkflowTimelineEntryKind, |
| 21 | 21 | ) |
| 22 | +from .workflow_signals import WorkflowSignalExtractor, WorkflowSignalPacket | |
| 22 | 23 | |
| 23 | 24 | __all__ = [ |
| 24 | 25 | "ArtifactFreshness", |
@@ -32,6 +33,8 @@ __all__ = [ | ||
| 32 | 33 | "WorkflowDecisionKind", |
| 33 | 34 | "WorkflowMode", |
| 34 | 35 | "WorkflowPolicy", |
| 36 | + "WorkflowSignalExtractor", | |
| 37 | + "WorkflowSignalPacket", | |
| 35 | 38 | "WorkflowTimelineEntry", |
| 36 | 39 | "WorkflowTimelineEntryKind", |
| 37 | 40 | "build_execute_bridge", |
src/loader/runtime/workflow_policy.pymodified@@ -9,6 +9,8 @@ from enum import StrEnum | ||
| 9 | 9 | from pathlib import Path |
| 10 | 10 | from typing import Any |
| 11 | 11 | |
| 12 | +from .workflow_signals import WorkflowSignalExtractor, WorkflowSignalPacket | |
| 13 | + | |
| 12 | 14 | |
| 13 | 15 | class WorkflowMode(StrEnum): |
| 14 | 16 | """High-level runtime modes for one Loader task turn.""" |
@@ -68,6 +70,7 @@ class ModeDecision: | ||
| 68 | 70 | scheduled_next_mode: WorkflowMode | None = None |
| 69 | 71 | unresolved_questions: list[str] = field(default_factory=list) |
| 70 | 72 | pressure_summary: list[str] = field(default_factory=list) |
| 73 | + signal_summary: list[str] = field(default_factory=list) | |
| 71 | 74 | |
| 72 | 75 | @property |
| 73 | 76 | def reason(self) -> str: |
@@ -89,6 +92,7 @@ class ModeDecision: | ||
| 89 | 92 | scheduled_next_mode: WorkflowMode | None = None, |
| 90 | 93 | unresolved_questions: list[str] | None = None, |
| 91 | 94 | pressure_summary: list[str] | None = None, |
| 95 | + signal_summary: list[str] | None = None, | |
| 92 | 96 | ) -> ModeDecision: |
| 93 | 97 | """Build a non-router workflow decision for handoffs and reentry.""" |
| 94 | 98 | |
@@ -105,6 +109,7 @@ class ModeDecision: | ||
| 105 | 109 | scheduled_next_mode=scheduled_next_mode, |
| 106 | 110 | unresolved_questions=list(unresolved_questions or []), |
| 107 | 111 | pressure_summary=list(pressure_summary or []), |
| 112 | + signal_summary=list(signal_summary or []), | |
| 108 | 113 | ) |
| 109 | 114 | |
| 110 | 115 | def with_context( |
@@ -119,6 +124,7 @@ class ModeDecision: | ||
| 119 | 124 | scheduled_next_mode: WorkflowMode | None = None, |
| 120 | 125 | unresolved_questions: list[str] | None = None, |
| 121 | 126 | pressure_summary: list[str] | None = None, |
| 127 | + signal_summary: list[str] | None = None, | |
| 122 | 128 | ) -> ModeDecision: |
| 123 | 129 | """Return a copy with updated contextual routing metadata.""" |
| 124 | 130 | |
@@ -149,6 +155,9 @@ class ModeDecision: | ||
| 149 | 155 | if pressure_summary is None |
| 150 | 156 | else pressure_summary |
| 151 | 157 | ), |
| 158 | + signal_summary=list( | |
| 159 | + self.signal_summary if signal_summary is None else signal_summary | |
| 160 | + ), | |
| 152 | 161 | ) |
| 153 | 162 | |
| 154 | 163 | |
@@ -190,6 +199,7 @@ class WorkflowTimelineEntry: | ||
| 190 | 199 | runner_up_score: float | None = None |
| 191 | 200 | scheduled_next_mode: str | None = None |
| 192 | 201 | unresolved_questions: list[str] = field(default_factory=list) |
| 202 | + signal_summary: list[str] = field(default_factory=list) | |
| 193 | 203 | prompt_format: str | None = None |
| 194 | 204 | prompt_sections: list[str] = field(default_factory=list) |
| 195 | 205 | artifact_paths: list[str] = field(default_factory=list) |
@@ -207,6 +217,7 @@ class WorkflowTimelineEntry: | ||
| 207 | 217 | "runner_up_score": self.runner_up_score, |
| 208 | 218 | "scheduled_next_mode": self.scheduled_next_mode, |
| 209 | 219 | "unresolved_questions": list(self.unresolved_questions), |
| 220 | + "signal_summary": list(self.signal_summary), | |
| 210 | 221 | "prompt_format": self.prompt_format, |
| 211 | 222 | "prompt_sections": list(self.prompt_sections), |
| 212 | 223 | "artifact_paths": list(self.artifact_paths), |
@@ -226,6 +237,7 @@ class WorkflowTimelineEntry: | ||
| 226 | 237 | runner_up_score=_optional_float(data.get("runner_up_score")), |
| 227 | 238 | scheduled_next_mode=_optional_text(data.get("scheduled_next_mode")), |
| 228 | 239 | unresolved_questions=_string_list(data.get("unresolved_questions")), |
| 240 | + signal_summary=_string_list(data.get("signal_summary")), | |
| 229 | 241 | prompt_format=_optional_text(data.get("prompt_format")), |
| 230 | 242 | prompt_sections=_string_list(data.get("prompt_sections")), |
| 231 | 243 | artifact_paths=_string_list(data.get("artifact_paths")), |
@@ -262,6 +274,7 @@ class WorkflowTimelineEntry: | ||
| 262 | 274 | else None |
| 263 | 275 | ), |
| 264 | 276 | unresolved_questions=list(decision.unresolved_questions), |
| 277 | + signal_summary=list(decision.signal_summary), | |
| 265 | 278 | prompt_format=prompt_format, |
| 266 | 279 | prompt_sections=list(prompt_sections or []), |
| 267 | 280 | artifact_paths=list(artifact_paths or []), |
@@ -274,6 +287,9 @@ class WorkflowPolicy: | ||
| 274 | 287 | clarify_threshold = 0.55 |
| 275 | 288 | plan_threshold = 0.45 |
| 276 | 289 | |
| 290 | + def __init__(self, signal_extractor: WorkflowSignalExtractor | None = None) -> None: | |
| 291 | + self.signal_extractor = signal_extractor or WorkflowSignalExtractor() | |
| 292 | + | |
| 277 | 293 | def route( |
| 278 | 294 | self, |
| 279 | 295 | task: str, |
@@ -286,8 +302,26 @@ class WorkflowPolicy: | ||
| 286 | 302 | mutating_history: bool = False, |
| 287 | 303 | stale_plan: bool = False, |
| 288 | 304 | unresolved_questions: list[str] | None = None, |
| 305 | + timeline: list[WorkflowTimelineEntry] | None = None, | |
| 289 | 306 | ) -> ModeDecision: |
| 290 | - unresolved_questions = list(unresolved_questions or []) | |
| 307 | + signals = self.signal_extractor.extract_route_signals( | |
| 308 | + task, | |
| 309 | + requested_mode=requested_mode.value if requested_mode is not None else None, | |
| 310 | + has_brief=has_brief, | |
| 311 | + has_plan=has_plan, | |
| 312 | + allow_clarify=allow_clarify, | |
| 313 | + verification_pressure=verification_pressure, | |
| 314 | + mutating_history=mutating_history, | |
| 315 | + stale_plan=stale_plan, | |
| 316 | + unresolved_questions=unresolved_questions, | |
| 317 | + timeline=timeline, | |
| 318 | + ) | |
| 319 | + return self.route_from_signals(signals) | |
| 320 | + | |
| 321 | + def route_from_signals(self, signals: WorkflowSignalPacket) -> ModeDecision: | |
| 322 | + """Route from a typed workflow-signal packet.""" | |
| 323 | + | |
| 324 | + requested_mode = WorkflowMode.from_str(signals.requested_mode) | |
| 291 | 325 | if requested_mode is not None: |
| 292 | 326 | return ModeDecision( |
| 293 | 327 | mode=requested_mode, |
@@ -300,9 +334,10 @@ class WorkflowPolicy: | ||
| 300 | 334 | if requested_mode in {WorkflowMode.CLARIFY, WorkflowMode.PLAN} |
| 301 | 335 | else None |
| 302 | 336 | ), |
| 337 | + signal_summary=list(signals.signal_summary), | |
| 303 | 338 | ) |
| 304 | 339 | |
| 305 | - if stale_plan: | |
| 340 | + if signals.stale_artifact_pressure > 0: | |
| 306 | 341 | return ModeDecision( |
| 307 | 342 | mode=WorkflowMode.PLAN, |
| 308 | 343 | reason_code="stale_plan_artifacts", |
@@ -312,14 +347,15 @@ class WorkflowPolicy: | ||
| 312 | 347 | runner_up_mode=WorkflowMode.EXECUTE, |
| 313 | 348 | runner_up_score=0.6, |
| 314 | 349 | scheduled_next_mode=WorkflowMode.EXECUTE, |
| 315 | - unresolved_questions=unresolved_questions, | |
| 350 | + unresolved_questions=list(signals.unresolved_questions), | |
| 316 | 351 | pressure_summary=[ |
| 317 | 352 | "plan refresh pressure: stale artifacts require a refreshed plan", |
| 318 | 353 | "execute pressure: continue directly with the stale artifacts", |
| 319 | 354 | ], |
| 355 | + signal_summary=list(signals.signal_summary), | |
| 320 | 356 | ) |
| 321 | 357 | |
| 322 | - if has_plan: | |
| 358 | + if signals.has_plan: | |
| 323 | 359 | return ModeDecision( |
| 324 | 360 | mode=WorkflowMode.EXECUTE, |
| 325 | 361 | reason_code="existing_plan_artifacts", |
@@ -328,45 +364,52 @@ class WorkflowPolicy: | ||
| 328 | 364 | route_score=0.9, |
| 329 | 365 | runner_up_mode=WorkflowMode.PLAN, |
| 330 | 366 | runner_up_score=0.45, |
| 331 | - unresolved_questions=unresolved_questions, | |
| 367 | + unresolved_questions=list(signals.unresolved_questions), | |
| 332 | 368 | pressure_summary=[ |
| 333 | 369 | "execute pressure: persisted plan artifacts already exist", |
| 334 | 370 | "plan pressure: a plan refresh is available but not required", |
| 335 | 371 | ], |
| 372 | + signal_summary=list(signals.signal_summary), | |
| 336 | 373 | ) |
| 337 | 374 | |
| 338 | - ambiguity = self._ambiguity_score(task) | |
| 339 | - complexity = self._complexity_score(task) | |
| 375 | + ambiguity = signals.ambiguity_score | |
| 376 | + complexity = signals.complexity_score | |
| 340 | 377 | |
| 341 | 378 | clarify_pressure = ambiguity |
| 342 | - if allow_clarify and not has_brief: | |
| 379 | + if signals.allow_clarify and not signals.has_brief: | |
| 343 | 380 | clarify_pressure += 0.15 |
| 344 | - if unresolved_questions: | |
| 345 | - clarify_pressure += 0.12 | |
| 381 | + if signals.unresolved_questions: | |
| 382 | + clarify_pressure += min(0.12, 0.04 * len(signals.unresolved_questions)) | |
| 346 | 383 | if complexity < 0.55: |
| 347 | 384 | clarify_pressure += 0.05 |
| 348 | - if not allow_clarify: | |
| 385 | + if signals.recent_clarify_count and signals.unresolved_questions: | |
| 386 | + clarify_pressure += 0.04 | |
| 387 | + if not signals.allow_clarify: | |
| 349 | 388 | clarify_pressure = 0.0 |
| 350 | 389 | |
| 351 | 390 | plan_pressure = complexity |
| 352 | - if verification_pressure: | |
| 353 | - plan_pressure += 0.12 | |
| 354 | - if mutating_history: | |
| 355 | - plan_pressure += 0.08 | |
| 356 | - if has_brief: | |
| 391 | + plan_pressure += signals.verification_pressure | |
| 392 | + plan_pressure += signals.mutation_pressure | |
| 393 | + if signals.has_brief: | |
| 357 | 394 | plan_pressure += 0.06 |
| 358 | - if unresolved_questions: | |
| 395 | + if signals.unresolved_questions: | |
| 359 | 396 | plan_pressure += 0.06 |
| 397 | + if signals.recent_reentry_count: | |
| 398 | + plan_pressure += 0.06 | |
| 399 | + if signals.recent_plan_refresh_count: | |
| 400 | + plan_pressure += 0.04 | |
| 360 | 401 | |
| 361 | 402 | execute_pressure = 0.35 |
| 362 | - if has_brief: | |
| 403 | + if signals.has_brief: | |
| 363 | 404 | execute_pressure += 0.14 |
| 364 | 405 | if ambiguity < 0.35: |
| 365 | 406 | execute_pressure += 0.16 |
| 366 | 407 | if complexity < 0.45: |
| 367 | 408 | execute_pressure += 0.12 |
| 368 | - if not unresolved_questions: | |
| 409 | + if not signals.unresolved_questions: | |
| 369 | 410 | execute_pressure += 0.05 |
| 411 | + if signals.recent_verify_skip_count and not signals.verification_pressure: | |
| 412 | + execute_pressure += 0.03 | |
| 370 | 413 | |
| 371 | 414 | scores = { |
| 372 | 415 | WorkflowMode.CLARIFY: round(min(clarify_pressure, 1.0), 3), |
@@ -385,7 +428,7 @@ class WorkflowPolicy: | ||
| 385 | 428 | if ( |
| 386 | 429 | winner == WorkflowMode.CLARIFY |
| 387 | 430 | and winner_score >= self.clarify_threshold |
| 388 | - and allow_clarify | |
| 431 | + and signals.allow_clarify | |
| 389 | 432 | ): |
| 390 | 433 | return ModeDecision( |
| 391 | 434 | mode=WorkflowMode.CLARIFY, |
@@ -397,19 +440,20 @@ class WorkflowPolicy: | ||
| 397 | 440 | runner_up_mode=runner_up, |
| 398 | 441 | runner_up_score=runner_up_score, |
| 399 | 442 | scheduled_next_mode=WorkflowMode.EXECUTE, |
| 400 | - unresolved_questions=unresolved_questions, | |
| 443 | + unresolved_questions=list(signals.unresolved_questions), | |
| 401 | 444 | pressure_summary=pressure_summary, |
| 445 | + signal_summary=list(signals.signal_summary), | |
| 402 | 446 | ) |
| 403 | 447 | |
| 404 | 448 | if winner == WorkflowMode.PLAN and winner_score >= self.plan_threshold: |
| 405 | 449 | reason_code = ( |
| 406 | 450 | "verification_pressure_requires_plan" |
| 407 | - if verification_pressure | |
| 451 | + if signals.verification_pressure | |
| 408 | 452 | else "task_is_complex" |
| 409 | 453 | ) |
| 410 | 454 | reason_summary = ( |
| 411 | 455 | "verification pressure and task complexity favor a persisted plan" |
| 412 | - if verification_pressure | |
| 456 | + if signals.verification_pressure | |
| 413 | 457 | else "workflow pressure favors a persisted plan before execution" |
| 414 | 458 | ) |
| 415 | 459 | return ModeDecision( |
@@ -422,8 +466,9 @@ class WorkflowPolicy: | ||
| 422 | 466 | runner_up_mode=runner_up, |
| 423 | 467 | runner_up_score=runner_up_score, |
| 424 | 468 | scheduled_next_mode=WorkflowMode.EXECUTE, |
| 425 | - unresolved_questions=unresolved_questions, | |
| 469 | + unresolved_questions=list(signals.unresolved_questions), | |
| 426 | 470 | pressure_summary=pressure_summary, |
| 471 | + signal_summary=list(signals.signal_summary), | |
| 427 | 472 | ) |
| 428 | 473 | |
| 429 | 474 | return ModeDecision( |
@@ -435,8 +480,9 @@ class WorkflowPolicy: | ||
| 435 | 480 | route_score=winner_score, |
| 436 | 481 | runner_up_mode=runner_up, |
| 437 | 482 | runner_up_score=runner_up_score, |
| 438 | - unresolved_questions=unresolved_questions, | |
| 483 | + unresolved_questions=list(signals.unresolved_questions), | |
| 439 | 484 | pressure_summary=pressure_summary, |
| 485 | + signal_summary=list(signals.signal_summary), | |
| 440 | 486 | ) |
| 441 | 487 | |
| 442 | 488 | def review_clarify( |
src/loader/runtime/workflow_signals.pyadded@@ -0,0 +1,221 @@ | ||
| 1 | +"""Typed workflow-signal extraction for runtime policy decisions.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import re | |
| 6 | +from dataclasses import dataclass, field | |
| 7 | +from typing import TYPE_CHECKING | |
| 8 | + | |
| 9 | +if TYPE_CHECKING: | |
| 10 | + from .workflow_policy import WorkflowTimelineEntry | |
| 11 | + | |
| 12 | + | |
| 13 | +@dataclass(slots=True) | |
| 14 | +class WorkflowSignalPacket: | |
| 15 | + """Typed route context consumed by workflow policy.""" | |
| 16 | + | |
| 17 | + task: str | |
| 18 | + requested_mode: str | None = None | |
| 19 | + has_brief: bool = False | |
| 20 | + has_plan: bool = False | |
| 21 | + allow_clarify: bool = True | |
| 22 | + ambiguity_score: float = 0.0 | |
| 23 | + complexity_score: float = 0.0 | |
| 24 | + verification_pressure: float = 0.0 | |
| 25 | + mutation_pressure: float = 0.0 | |
| 26 | + artifact_reuse_pressure: float = 0.0 | |
| 27 | + stale_artifact_pressure: float = 0.0 | |
| 28 | + unresolved_questions: list[str] = field(default_factory=list) | |
| 29 | + recent_clarify_count: int = 0 | |
| 30 | + recent_reentry_count: int = 0 | |
| 31 | + recent_plan_refresh_count: int = 0 | |
| 32 | + recent_verify_skip_count: int = 0 | |
| 33 | + signal_summary: list[str] = field(default_factory=list) | |
| 34 | + | |
| 35 | + | |
| 36 | +class WorkflowSignalExtractor: | |
| 37 | + """Build typed workflow-signal packets from runtime state.""" | |
| 38 | + | |
| 39 | + def extract_route_signals( | |
| 40 | + self, | |
| 41 | + task: str, | |
| 42 | + *, | |
| 43 | + requested_mode: str | None = None, | |
| 44 | + has_brief: bool = False, | |
| 45 | + has_plan: bool = False, | |
| 46 | + allow_clarify: bool = True, | |
| 47 | + verification_pressure: bool = False, | |
| 48 | + mutating_history: bool = False, | |
| 49 | + stale_plan: bool = False, | |
| 50 | + unresolved_questions: list[str] | None = None, | |
| 51 | + timeline: list[WorkflowTimelineEntry] | None = None, | |
| 52 | + ) -> WorkflowSignalPacket: | |
| 53 | + """Derive workflow signals from task state and recent timeline context.""" | |
| 54 | + | |
| 55 | + unresolved_questions = list(unresolved_questions or []) | |
| 56 | + recent_timeline = list(timeline or [])[-6:] | |
| 57 | + recent_clarify_count = sum( | |
| 58 | + 1 | |
| 59 | + for entry in recent_timeline | |
| 60 | + if entry.mode == "clarify" or entry.kind.startswith("clarify") | |
| 61 | + ) | |
| 62 | + recent_reentry_count = sum( | |
| 63 | + 1 | |
| 64 | + for entry in recent_timeline | |
| 65 | + if entry.kind == "reentry" or entry.decision_kind == "reentry" | |
| 66 | + ) | |
| 67 | + recent_plan_refresh_count = sum( | |
| 68 | + 1 | |
| 69 | + for entry in recent_timeline | |
| 70 | + if entry.kind == "plan_refresh" or "plan_refresh" in entry.reason_code | |
| 71 | + ) | |
| 72 | + recent_verify_skip_count = sum( | |
| 73 | + 1 | |
| 74 | + for entry in recent_timeline | |
| 75 | + if entry.kind == "verify_skip" | |
| 76 | + ) | |
| 77 | + ambiguity_score = self._ambiguity_score(task) | |
| 78 | + complexity_score = self._complexity_score(task) | |
| 79 | + verification_signal = 0.18 if verification_pressure else 0.0 | |
| 80 | + mutation_signal = 0.12 if mutating_history else 0.0 | |
| 81 | + artifact_reuse_signal = 0.2 if has_plan else 0.0 | |
| 82 | + stale_artifact_signal = 0.45 if stale_plan else 0.0 | |
| 83 | + | |
| 84 | + signal_summary: list[str] = [ | |
| 85 | + f"ambiguity={ambiguity_score:.2f}", | |
| 86 | + f"complexity={complexity_score:.2f}", | |
| 87 | + ] | |
| 88 | + if requested_mode: | |
| 89 | + signal_summary.append(f"requested_mode={requested_mode}") | |
| 90 | + if has_brief: | |
| 91 | + signal_summary.append("clarify_brief=available") | |
| 92 | + if has_plan: | |
| 93 | + signal_summary.append("plan_artifacts=available") | |
| 94 | + if stale_plan: | |
| 95 | + signal_summary.append("plan_artifacts=stale") | |
| 96 | + if unresolved_questions: | |
| 97 | + signal_summary.append( | |
| 98 | + f"open_questions={min(len(unresolved_questions), 9)}" | |
| 99 | + ) | |
| 100 | + if verification_pressure: | |
| 101 | + signal_summary.append("verification_pressure=active") | |
| 102 | + if mutating_history: | |
| 103 | + signal_summary.append("mutation_pressure=active") | |
| 104 | + if recent_clarify_count: | |
| 105 | + signal_summary.append(f"recent_clarify={recent_clarify_count}") | |
| 106 | + if recent_reentry_count: | |
| 107 | + signal_summary.append(f"recent_reentry={recent_reentry_count}") | |
| 108 | + if recent_plan_refresh_count: | |
| 109 | + signal_summary.append(f"recent_plan_refresh={recent_plan_refresh_count}") | |
| 110 | + if recent_verify_skip_count: | |
| 111 | + signal_summary.append(f"recent_verify_skip={recent_verify_skip_count}") | |
| 112 | + | |
| 113 | + return WorkflowSignalPacket( | |
| 114 | + task=task, | |
| 115 | + requested_mode=requested_mode, | |
| 116 | + has_brief=has_brief, | |
| 117 | + has_plan=has_plan, | |
| 118 | + allow_clarify=allow_clarify, | |
| 119 | + ambiguity_score=ambiguity_score, | |
| 120 | + complexity_score=complexity_score, | |
| 121 | + verification_pressure=verification_signal, | |
| 122 | + mutation_pressure=mutation_signal, | |
| 123 | + artifact_reuse_pressure=artifact_reuse_signal, | |
| 124 | + stale_artifact_pressure=stale_artifact_signal, | |
| 125 | + unresolved_questions=unresolved_questions, | |
| 126 | + recent_clarify_count=recent_clarify_count, | |
| 127 | + recent_reentry_count=recent_reentry_count, | |
| 128 | + recent_plan_refresh_count=recent_plan_refresh_count, | |
| 129 | + recent_verify_skip_count=recent_verify_skip_count, | |
| 130 | + signal_summary=signal_summary, | |
| 131 | + ) | |
| 132 | + | |
| 133 | + @staticmethod | |
| 134 | + def _ambiguity_score(task: str) -> float: | |
| 135 | + lowered = task.lower() | |
| 136 | + words = re.findall(r"\w+", lowered) | |
| 137 | + score = 0.0 | |
| 138 | + | |
| 139 | + if ( | |
| 140 | + "--clarify" in lowered | |
| 141 | + or "don't assume" in lowered | |
| 142 | + or "do not assume" in lowered | |
| 143 | + or "not sure" in lowered | |
| 144 | + or "figure out" in lowered | |
| 145 | + or "interview me" in lowered | |
| 146 | + or "ask me" in lowered | |
| 147 | + or lowered.startswith("clarify ") | |
| 148 | + ): | |
| 149 | + score += 0.65 | |
| 150 | + | |
| 151 | + if any( | |
| 152 | + phrase in lowered | |
| 153 | + for phrase in ( | |
| 154 | + "something", | |
| 155 | + "somehow", | |
| 156 | + "better", | |
| 157 | + "improve", | |
| 158 | + "fix this", | |
| 159 | + "make it", | |
| 160 | + "more like", | |
| 161 | + "feels more like", | |
| 162 | + ) | |
| 163 | + ): | |
| 164 | + score += 0.2 | |
| 165 | + | |
| 166 | + if not _has_concrete_anchor(task): | |
| 167 | + score += 0.2 | |
| 168 | + | |
| 169 | + if len(words) <= 12 and any( | |
| 170 | + verb in lowered | |
| 171 | + for verb in ("build", "add", "improve", "refactor", "implement") | |
| 172 | + ): | |
| 173 | + score += 0.15 | |
| 174 | + | |
| 175 | + return round(min(score, 1.0), 3) | |
| 176 | + | |
| 177 | + @staticmethod | |
| 178 | + def _complexity_score(task: str) -> float: | |
| 179 | + lowered = task.lower() | |
| 180 | + words = re.findall(r"\w+", lowered) | |
| 181 | + score = 0.0 | |
| 182 | + | |
| 183 | + if len(words) >= 18: | |
| 184 | + score += 0.2 | |
| 185 | + if len(words) >= 30: | |
| 186 | + score += 0.15 | |
| 187 | + | |
| 188 | + if any( | |
| 189 | + phrase in lowered | |
| 190 | + for phrase in ( | |
| 191 | + "refactor", | |
| 192 | + "architecture", | |
| 193 | + "migrate", | |
| 194 | + "persistent", | |
| 195 | + "workflow", | |
| 196 | + "deep dive", | |
| 197 | + "report", | |
| 198 | + "implementation plan", | |
| 199 | + "verification plan", | |
| 200 | + ) | |
| 201 | + ): | |
| 202 | + score += 0.3 | |
| 203 | + | |
| 204 | + if lowered.count(" and ") >= 2 or lowered.count(",") >= 2: | |
| 205 | + score += 0.15 | |
| 206 | + | |
| 207 | + if _has_concrete_anchor(task): | |
| 208 | + score += 0.1 | |
| 209 | + | |
| 210 | + return round(min(score, 1.0), 3) | |
| 211 | + | |
| 212 | + | |
| 213 | +def _has_concrete_anchor(task: str) -> bool: | |
| 214 | + return bool( | |
| 215 | + re.search(r"[./_\\-]", task) | |
| 216 | + or re.search(r"`[^`]+`", task) | |
| 217 | + or any( | |
| 218 | + token in task.lower() | |
| 219 | + for token in ("test", "file", "function", "class") | |
| 220 | + ) | |
| 221 | + ) | |
tests/test_workflow_policy.pymodified@@ -8,6 +8,7 @@ from loader.runtime.workflow import ( | ||
| 8 | 8 | WorkflowTimelineEntry, |
| 9 | 9 | WorkflowTimelineEntryKind, |
| 10 | 10 | ) |
| 11 | +from loader.runtime.workflow_signals import WorkflowSignalPacket | |
| 11 | 12 | |
| 12 | 13 | |
| 13 | 14 | def test_workflow_policy_reports_winner_and_runner_up() -> None: |
@@ -20,6 +21,24 @@ def test_workflow_policy_reports_winner_and_runner_up() -> None: | ||
| 20 | 21 | assert decision.runner_up_mode is not None |
| 21 | 22 | assert decision.runner_up_score > 0 |
| 22 | 23 | assert decision.pressure_summary |
| 24 | + assert decision.signal_summary | |
| 25 | + | |
| 26 | + | |
| 27 | +def test_workflow_policy_routes_from_typed_signal_packet() -> None: | |
| 28 | + policy = WorkflowPolicy() | |
| 29 | + | |
| 30 | + decision = policy.route_from_signals( | |
| 31 | + WorkflowSignalPacket( | |
| 32 | + task="Keep improving Loader.", | |
| 33 | + ambiguity_score=0.62, | |
| 34 | + complexity_score=0.28, | |
| 35 | + allow_clarify=True, | |
| 36 | + signal_summary=["ambiguity=0.62", "complexity=0.28"], | |
| 37 | + ) | |
| 38 | + ) | |
| 39 | + | |
| 40 | + assert decision.mode == WorkflowMode.CLARIFY | |
| 41 | + assert decision.signal_summary == ["ambiguity=0.62", "complexity=0.28"] | |
| 23 | 42 | |
| 24 | 43 | |
| 25 | 44 | def test_workflow_policy_prefers_plan_refresh_for_stale_plan() -> None: |
@@ -79,6 +98,7 @@ def test_workflow_timeline_entry_round_trips() -> None: | ||
| 79 | 98 | runner_up_score=0.66, |
| 80 | 99 | scheduled_next_mode="execute", |
| 81 | 100 | unresolved_questions=["Scope is still broad."], |
| 101 | + signal_summary=["ambiguity=0.20", "complexity=0.81"], | |
| 82 | 102 | prompt_format="native", |
| 83 | 103 | prompt_sections=["Runtime Config", "Workflow Context"], |
| 84 | 104 | artifact_paths=["/tmp/implementation.md"], |
tests/test_workflow_signals.pyadded@@ -0,0 +1,55 @@ | ||
| 1 | +"""Tests for typed workflow-signal extraction.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from loader.runtime.workflow import ( | |
| 6 | + WorkflowSignalExtractor, | |
| 7 | + WorkflowTimelineEntry, | |
| 8 | +) | |
| 9 | + | |
| 10 | + | |
| 11 | +def test_workflow_signal_extractor_captures_recent_timeline_pressure() -> None: | |
| 12 | + extractor = WorkflowSignalExtractor() | |
| 13 | + timeline = [ | |
| 14 | + WorkflowTimelineEntry( | |
| 15 | + timestamp="2026-04-07T12:00:00Z", | |
| 16 | + kind="clarify_continue", | |
| 17 | + mode="clarify", | |
| 18 | + reason_code="clarify_follow_up_needed", | |
| 19 | + summary="clarify: clarify pressure remains high", | |
| 20 | + decision_kind="forced", | |
| 21 | + ), | |
| 22 | + WorkflowTimelineEntry( | |
| 23 | + timestamp="2026-04-07T12:01:00Z", | |
| 24 | + kind="reentry", | |
| 25 | + mode="execute", | |
| 26 | + reason_code="verification_failed_reentry", | |
| 27 | + summary="execute: verification failed; returning to execute", | |
| 28 | + decision_kind="reentry", | |
| 29 | + ), | |
| 30 | + WorkflowTimelineEntry( | |
| 31 | + timestamp="2026-04-07T12:02:00Z", | |
| 32 | + kind="verify_skip", | |
| 33 | + mode="verify", | |
| 34 | + reason_code="verification_not_required", | |
| 35 | + summary="verify: verification skipped", | |
| 36 | + decision_kind="forced", | |
| 37 | + ), | |
| 38 | + ] | |
| 39 | + | |
| 40 | + signals = extractor.extract_route_signals( | |
| 41 | + "Improve Loader so it feels more like claw-code.", | |
| 42 | + has_brief=True, | |
| 43 | + unresolved_questions=["Scope is still broad."], | |
| 44 | + timeline=timeline, | |
| 45 | + ) | |
| 46 | + | |
| 47 | + assert signals.ambiguity_score > 0 | |
| 48 | + assert signals.has_brief is True | |
| 49 | + assert signals.recent_clarify_count == 1 | |
| 50 | + assert signals.recent_reentry_count == 1 | |
| 51 | + assert signals.recent_verify_skip_count == 1 | |
| 52 | + assert "clarify_brief=available" in signals.signal_summary | |
| 53 | + assert "open_questions=1" in signals.signal_summary | |
| 54 | + assert "recent_reentry=1" in signals.signal_summary | |
| 55 | + | |