Files
cdsl-cad/backend/app/cad_agent/application/workflow.py
T
2026-09-01 14:10:23 +08:00

1467 lines
90 KiB
Python

"""LLM turn coordinator for protocol v3.
It selects only which schema is visible from the persisted state. It never
derives a CAD operation from requirements; that choice is always an author
tool call handled by ``ActionCommandHandler``.
"""
from __future__ import annotations
import json
import secrets
from hashlib import sha256
from dataclasses import dataclass
from typing import Any, AsyncIterator, TypeVar
from pydantic import BaseModel
from app.cad_agent.application.action_handlers import ActionCommandHandler
from app.cad_agent.application.llm_contracts import (
CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, NextAction,
OperationContractRequest, RequirementsDraftBatch, RequirementsPatchBatch,
RequirementsReview, RollbackCheckpoint, TopologyRequest,
candidate_review_schema, canonical_json_object, canonical_validate, canonical_validate_schema,
final_review_schema, geometry_conclusion_schema, next_action_schema,
operation_contract_request_schema, rollback_checkpoint_schema, topology_request_schema,
raw_arguments_hash, validate_one_tool_call,
)
from app.cad_agent.application.requirements_review import RequirementsCommandHandler
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.state import TaskPhase, TaskState, reject_stale_head, retry_resume_event, transition
from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository
T = TypeVar("T", bound=BaseModel)
@dataclass(frozen=True, slots=True)
class ModelIdentity:
provider_id: str
model_id: str
@dataclass(frozen=True, slots=True)
class WorkflowConfig:
max_turns: int
format_error_limit: int
requirements_review_limit: int = 3
author_fallbacks: tuple[ModelIdentity, ...] = ()
max_author_turns: int | None = None
max_reviewer_turns: int | None = None
max_model_calls: int | None = None
@dataclass(slots=True)
class _ModelCallBudget:
"""Task-scoped model-call caps, including calls from a prior resume."""
max_author_turns: int | None
max_reviewer_turns: int | None
max_model_calls: int | None
author_calls: int = 0
reviewer_calls: int = 0
@classmethod
def from_usage(cls, config: WorkflowConfig, records: list[dict[str, Any]]) -> "_ModelCallBudget":
reviewer_calls = sum(1 for record in records if record.get("role") == "reviewer")
return cls(
max_author_turns=config.max_author_turns,
max_reviewer_turns=config.max_reviewer_turns,
max_model_calls=config.max_model_calls,
author_calls=len(records) - reviewer_calls,
reviewer_calls=reviewer_calls,
)
@property
def total_calls(self) -> int:
return self.author_calls + self.reviewer_calls
def exhausted(self, actor: str) -> bool:
return (
(actor == "author" and self.max_author_turns is not None and self.author_calls >= self.max_author_turns)
or (actor == "reviewer" and self.max_reviewer_turns is not None and self.reviewer_calls >= self.max_reviewer_turns)
or (self.max_model_calls is not None and self.total_calls >= self.max_model_calls)
)
def record_attempt(self, actor: str) -> None:
if actor == "reviewer":
self.reviewer_calls += 1
else:
self.author_calls += 1
def payload(self) -> dict[str, int | None]:
return {
"author_calls": self.author_calls,
"reviewer_calls": self.reviewer_calls,
"total_calls": self.total_calls,
"max_author_turns": self.max_author_turns,
"max_reviewer_turns": self.max_reviewer_turns,
"max_model_calls": self.max_model_calls,
}
class WorkflowCoordinator:
def __init__(
self,
config: WorkflowConfig,
repository: TaskRepository,
artifacts: ArtifactStore,
runtime: CadRuntime,
model_gateway: ModelGateway,
review_gateway: ReviewGateway,
requirements: RequirementsCommandHandler,
actions: ActionCommandHandler,
) -> None:
self.config = config
self.repository = repository
self.artifacts = artifacts
self.runtime = runtime
self.model_gateway = model_gateway
self.review_gateway = review_gateway
self.requirements = requirements
self.actions = actions
def create_task(
self,
task_id: str,
request: str,
*,
source_blocks: list[dict[str, Any]] | None = None,
) -> TaskState:
# The immutable source artifact is safe to create before SQLite state:
# an interrupted creation leaves only an unreferenced directory, never
# a runnable task without its source index.
self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks)
return self.repository.create_task(task_id, request)
def resume(self, task_id: str) -> bool:
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.WAITING_RETRY:
return False
event = retry_resume_event(state)
if not event:
return False
next_state = transition(state, event)
return self.repository.compare_and_swap(next_state, events=[
{
"event": "workflow_resumed",
"from_phase": state.phase.value,
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
"to_phase": next_state.phase.value,
},
])
def resume_with_user_clarification(self, task_id: str, clarification: str, *, message_id: str) -> bool:
"""Resume a requirements pause on the same task with durable input."""
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.WAITING_FOR_USER:
return False
review = self._requirements_review(task_id, state)
is_requirements_pause = isinstance(review, dict) and (
review.get("decision") == "waiting_for_user"
and any(
item.get("finding_type") == "ambiguous_conflict" and item.get("question")
for item in review.get("findings") or () if isinstance(item, dict)
)
)
if not is_requirements_pause:
return False
text = clarification.strip()
if not text:
return False
digest = sha256(f"{message_id}:{text}".encode("utf-8")).hexdigest()[:16]
clarification_path = f"documents/user-clarification-{digest}.json"
try:
self.artifacts.write_json_once(task_id, clarification_path, {
"schema_version": "cad.user-clarification.v1",
"task_id": task_id,
"message_id": message_id,
"text": text,
})
except OSError:
return False
resumed = transition(state, "requirements_clarified")
return self.repository.compare_and_swap(resumed, events=[{
"event": "user_clarification_received",
"message_id": message_id,
"clarification_path": clarification_path,
"response_sha256": sha256(text.encode("utf-8")).hexdigest(),
}])
async def run(self, *, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> AsyncIterator[tuple[str, dict[str, Any]]]:
feedback: list[dict[str, Any]] = []
format_errors: dict[str, int] = {}
transport_attempted: set[str] = set()
# Observations are request-scoped, read-only facts. A restarted run
# deliberately has to fetch them again before authoring a selector-
# bound feature.
action_observations: dict[str, set[str]] = {}
active_author = author
max_turns = self.config.max_turns
call_budget = _ModelCallBudget.from_usage(
self.config,
self.repository.usage_summary(task_id).get("records", []),
)
try:
initial = self.repository.get_state(task_id)
if initial is not None:
referenced = {initial.candidate_stage_id} if initial.candidate_stage_id else set()
# Candidate rejection/build-failure evidence is itself an
# immutable repair input. Once the state transition clears
# ``candidate_stage_id``, retain that stage through restart
# based on its committed ledger reference.
referenced.update(
str(event["stage_id"])
for event in self.repository.ledger_events(task_id)
if event.get("event") in {
"candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", "candidate_recovered_rejected",
}
and isinstance(event.get("stage_id"), str)
and event["stage_id"]
)
try:
self.artifacts.recover_staged_candidates(task_id, referenced)
except Exception as error:
yield self._storage_failure(task_id, str(error))
return
for _turn in range(max_turns):
try:
self._sync_action_ledger(task_id)
except Exception as error:
yield self._storage_failure(task_id, str(error))
return
state = self.repository.get_state(task_id)
if state is None:
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."}
return
if state.requirements_contract_path and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
try:
self.requirements.ensure_rendered_contract_views(task_id, state)
except Exception as error:
yield self._service_failure(
task_id,
state,
WorkflowError(ErrorCode.STORAGE_FAILURE, str(error)[:1000], retryable=True),
)
return
if state.phase == TaskPhase.COMPLETED:
yield "task_terminal", self._projected_terminal(task_id, state)
return
if state.phase == TaskPhase.CANCELLED:
yield "task_terminal", {
"taskId": task_id,
"lifecycle": "cancelled",
"revisionId": state.active_revision,
"code": ErrorCode.CANCELLED.value,
}
return
if state.phase == TaskPhase.WAITING_FOR_USER:
yield "task_terminal", self.waiting_for_user_terminal(task_id, state)
return
if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY}:
yield "task_terminal", self._projected_terminal(task_id, state)
return
if state.phase == TaskPhase.CANDIDATE_BUILDING:
recovered = self.actions.recover_candidate_build(task_id)
yield "candidate_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
if isinstance(recovered, Rejected):
yield self._service_failure(task_id, state, recovered.error)
return
continue
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
draft_schema = self.requirements.draft_schema(task_id)
patch_schema = self.requirements.patch_schema(task_id)
tools = self._requirements_author_tools(task_id, draft_schema, patch_schema)
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
model = RequirementsDraftBatch if name == "submit_requirements_draft_batch" else RequirementsPatchBatch if name == "patch_requirements_draft" else EmptyCommand
validation = canonical_validate(raw, model)
dynamic_error = canonical_validate_schema(raw, draft_schema if name == "submit_requirements_draft_batch" else patch_schema) if not isinstance(validation, WorkflowError) and name != "finalize_requirements_draft" else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
invocation_id = self._invocation_id(task_id)
command = self.requirements.submit_draft(task_id, validation, invocation_id=invocation_id) if name == "submit_requirements_draft_batch" else self.requirements.patch_draft(task_id, validation, invocation_id=invocation_id) if name == "patch_requirements_draft" else self.requirements.finalize_draft(task_id, invocation_id=invocation_id)
if isinstance(command, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
yield "tool_call", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.REVIEWING_REQUIREMENTS:
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
if terminal:
yield terminal
return
call_budget.record_attempt("reviewer")
command = await self._review_requirements(task_id, reviewer, feedback)
if isinstance(command, WorkflowError):
if command.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "review_requirements", command, format_errors, feedback, actor="reviewer")
yield "requirements_review", {"taskId": task_id, "status": "error", "result": command.payload()}
if terminal:
yield terminal
return
continue
terminal = self._service_failure(task_id, state, command)
yield terminal
return
review_result = self.requirements.record_review(task_id, command, invocation_id=self._invocation_id(task_id))
if isinstance(review_result, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, "review_requirements", review_result.error, format_errors, feedback, actor="reviewer")
yield "requirements_review", {"taskId": task_id, "status": "error", "result": review_result.error.payload()}
if terminal:
yield terminal
return
continue
yield "requirements_review", {"taskId": task_id, "status": "success", "result": self._result_payload(review_result)}
if isinstance(review_result, Accepted) and review_result.payload.get("phase") == TaskPhase.DRAFTING_REQUIREMENTS.value:
draft_state = self.repository.get_state(task_id)
revision = int(self._requirements_draft(draft_state).get("revision") or 0)
# The initial draft is reviewed before any revision.
# ``requirements_review_limit`` bounds subsequent
# author corrections, so permit exactly that many
# patches and park only after they are exhausted.
if revision > self.config.requirements_review_limit:
if draft_state is not None:
failed = transition(draft_state, "failed", error=ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED)
review = self._requirements_review(task_id, draft_state) or {}
findings = [
item for item in review.get("findings") or ()
if isinstance(item, dict)
]
self.repository.compare_and_swap(failed, events=[{
"event": "requirements_review_limit_reached",
"code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value,
"draft_revision": revision,
"review_path": draft_state.requirements_review_path,
"findings": findings,
}])
yield "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value,
"message": "Requirements review did not converge within the configured author-revision limit.",
"issues": [str(item.get("description") or "") for item in findings if item.get("description")],
"blockerType": "requirements_review_not_converged",
"userActionRequired": False,
}
return
continue
if state.phase == TaskPhase.AWAITING_ACTION:
contract = self._requirements_contract(task_id, state) or {}
requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")]
action_schema = next_action_schema(
state.working_head,
requirement_ids,
list(self.actions.available_atomic_ids(task_id, state)),
)
tools = self._recovery_tools(task_id, state)
if not tools:
if self._can_complete(task_id, state):
tools = [self._tool("complete_task", EmptyCommand)]
elif self.actions.repair_action_ready(task_id, state):
tools = [self._tool("propose_next_action", action_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
if name == "complete_task":
validation = canonical_validate(raw, EmptyCommand)
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.actions.complete_task(task_id, invocation_id=self._invocation_id(task_id))
elif name == "record_geometry_conclusion":
diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state)))
validation = canonical_validate(raw, GeometryConclusion)
dynamic_error = canonical_validate_schema(raw, diagnostic_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
elif name == "rollback_checkpoint":
rollback_schema = rollback_checkpoint_schema(state.working_head, list(self.actions.checkpoint_tokens(task_id, state)))
validation = canonical_validate(raw, RollbackCheckpoint)
dynamic_error = canonical_validate_schema(raw, rollback_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id))
else:
validation = canonical_validate(raw, NextAction)
dynamic_error = canonical_validate_schema(raw, action_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.actions.propose_next_action(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
yield "action_selection", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
yield "action_selection", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.ACTION_PENDING:
observed = action_observations.setdefault(state.working_head, set())
tools = self._recovery_tools(task_id, state)
if not tools and self.actions.repair_action_ready(task_id, state):
tools = self._action_tools(task_id, state, observed)
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
if name == "record_geometry_conclusion":
diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state)))
validation = canonical_validate(raw, GeometryConclusion)
dynamic_error = canonical_validate_schema(raw, diagnostic_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
yield "tool_call", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if name == "inspect_topology":
validation = canonical_validate(raw, TopologyRequest)
dynamic_error = canonical_validate_schema(raw, topology_request_schema(state.working_head)) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
stale = reject_stale_head(state, validation.working_head)
payload = stale.payload() if stale else self._topology_payload(task_id, state, validation.kind, validation.limit)
if stale is None:
observed.add("topology")
yield "tool_call", self._event(task_id, name, payload, "error" if stale else "success", usage)
feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:]
continue
if name == "get_cdsl_operation_contract":
validation = canonical_validate(raw, OperationContractRequest)
action = state.pending_action
dynamic_error = canonical_validate_schema(raw, operation_contract_request_schema(state.working_head, action.atomic_id)) if not isinstance(validation, WorkflowError) and action is not None else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
action = state.pending_action
stale = reject_stale_head(state, validation.working_head)
if stale:
payload = stale.payload()
elif action is None or validation.atomic_id != action.atomic_id:
payload = WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Only the pending action's operation contract is available.").payload()
else:
payload = self._operation_payload(task_id, state)
observed.add("contract")
yield "tool_call", self._event(task_id, name, payload, "error" if "code" in payload else "success", usage)
feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:]
continue
fragment = canonical_json_object(raw)
if isinstance(fragment, WorkflowError):
validation_error = fragment
terminal = self._format_failure(task_id, state, name, validation_error, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation_error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.actions.submit_cdsl_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
# A candidate build may reject and advance back to
# ACTION_PENDING. That is repair work, not a no-side-
# effect schema/state retry.
after = self.repository.get_state(task_id)
if after is not None and after.version != state.version:
feedback = [self._feedback(command.error)]
yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage)
continue
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
yield "candidate_result", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.CANDIDATE_REVIEW:
recovered = self.actions.recover_candidate_review(task_id)
if recovered is not None:
yield "candidate_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
if isinstance(recovered, Rejected):
yield self._service_failure(task_id, state, recovered.error)
return
continue
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
if terminal:
yield terminal
return
call_budget.record_attempt("reviewer")
review = await self._review_candidate(task_id, reviewer, state, feedback)
if isinstance(review, WorkflowError):
if review.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "review_candidate", review, format_errors, feedback, actor="reviewer")
yield "candidate_review", {"taskId": task_id, "status": "error", "result": review.payload()}
if terminal:
yield terminal
return
continue
yield self._service_failure(task_id, state, review)
return
command = self.actions.record_candidate_review(task_id, review, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, "review_candidate", command.error, format_errors, feedback, actor="reviewer")
yield "candidate_review", {"taskId": task_id, "status": "error", "result": command.error.payload()}
if terminal:
yield terminal
return
continue
yield "candidate_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
if state.phase == TaskPhase.FINAL_VALIDATION:
recovered = self.actions.recover_final_review(task_id)
if recovered is not None:
yield "final_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
if isinstance(recovered, Rejected):
yield self._service_failure(task_id, state, recovered.error)
return
continue
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
if terminal:
yield terminal
return
call_budget.record_attempt("reviewer")
review = await self._review_final(task_id, reviewer, state, feedback)
if isinstance(review, WorkflowError):
if review.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "review_final", review, format_errors, feedback, actor="reviewer")
yield "final_review", {"taskId": task_id, "status": "error", "result": review.payload()}
if terminal:
yield terminal
return
continue
yield self._service_failure(task_id, state, review)
return
command = self.actions.record_final_review(task_id, review, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, "review_final", command.error, format_errors, feedback, actor="reviewer")
yield "final_review", {"taskId": task_id, "status": "error", "result": command.error.payload()}
if terminal:
yield terminal
return
continue
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
state = self.repository.get_state(task_id)
if state:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}])
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Workflow exceeded its finite turn limit."}
except OSError as error:
yield self._storage_failure(task_id, str(error))
except Exception as error:
state = self.repository.get_state(task_id)
if state and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": str(error)[:1000]}])
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]}
finally:
# The database ledger is authoritative. A failed mirror write is
# retried by the next active workflow pass; do not replace a
# completed/cancelled durable result with a filesystem exception.
try:
self._sync_action_ledger(task_id)
except Exception:
pass
def _sync_action_ledger(self, task_id: str) -> None:
self.artifacts.sync_action_ledger(task_id, self.repository.ledger_events(task_id))
def _call_budget_terminal(
self,
task_id: str,
state: TaskState,
budget: _ModelCallBudget,
*,
actor: str,
) -> tuple[str, dict[str, Any]] | None:
if not budget.exhausted(actor):
return None
details = budget.payload()
details["next_actor"] = actor
failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED)
self.repository.compare_and_swap(failed, events=[{
"event": "call_budget_exhausted",
"code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
"message": "Configured model-call budget is exhausted before the workflow converged.",
**details,
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
"message": "Configured model-call budget is exhausted before the workflow converged.",
"budget": details,
"blockerType": "call_budget_exhausted",
"userActionRequired": False,
}
def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]:
"""Park a nonterminal task when a durable artifact operation fails."""
state = self.repository.get_state(task_id)
if state is not None and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}:
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
self.repository.compare_and_swap(waiting, events=[{
"event": "waiting_retry",
"code": ErrorCode.STORAGE_FAILURE.value,
"message": message[:1000],
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "waiting_retry",
"code": ErrorCode.STORAGE_FAILURE.value,
"message": message[:1000],
}
lifecycle = state.phase.value.lower() if state is not None else "failed"
return "task_terminal", {
"taskId": task_id,
"lifecycle": lifecycle,
"code": ErrorCode.STORAGE_FAILURE.value,
"message": message[:1000],
}
async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, int]] | WorkflowError:
if len(tools) != 1:
raise RuntimeError("Workflow state must expose exactly one author tool.")
tool = tools[0]
name = str((tool.get("function") or {}).get("name") or "")
if not name:
raise RuntimeError("Workflow exposed an unnamed author tool.")
messages = self._author_context(task_id, feedback)
try:
response = await self.model_gateway.call_tool(
messages=messages, tool=tool, provider_id=author.provider_id,
model_id=author.model_id, required_tool_name=name,
)
except AdapterUnavailable as error:
self.repository.record_usage(task_id, {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"usage_available": False,
"context_chars": len(json.dumps(messages, ensure_ascii=False)),
"tool": name,
"provider_id": author.provider_id,
"model_id": author.model_id,
"retry_reason": "provider_unavailable",
"cache_hit": False,
})
return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True)
call = validate_one_tool_call(response["tool_calls"], name)
if isinstance(call, WorkflowError):
self._record_rejected_tool_calls(
task_id,
actor="author",
expected_tool=name,
tool_calls=response["tool_calls"],
schema=(tool.get("function") or {}).get("parameters"),
)
return call
_name, raw = call
self._record_tool_audit(
task_id,
actor="author",
tool=name,
raw_arguments=raw,
schema=(tool.get("function") or {}).get("parameters"),
)
usage = {
**response["usage"],
"context_chars": len(json.dumps(messages, ensure_ascii=False)),
"tool": name,
"provider_id": author.provider_id,
"model_id": author.model_id,
"raw_arguments_hash": raw_arguments_hash(raw),
"retry_reason": "",
"cache_hit": False,
}
self.repository.record_usage(task_id, usage)
return name, raw, usage
async def _review_requirements(self, task_id: str, reviewer: ModelIdentity, feedback: list[dict[str, Any]] | None = None) -> RequirementsReview | WorkflowError:
state = self.repository.get_state(task_id)
evaluation_context = self.requirements.evaluation_review_context(task_id)
instruction = "Return only structured findings; the service derives coverage and the overall decision. Omit a draft item when it has no finding. Bind every finding to one current draft_id and only source_ids cited by that draft. Use missing_source_semantics for omitted source meaning, claim_mismatch for an incorrect deterministic claim, verification_gap only when a scoped visual claim covers a property unavailable to deterministic verifiers, derivable_conflict only with a registered normalization payload, and ambiguous_conflict only when multiple reasonable interpretations remain; ambiguous conflicts require one precise answerable question. For a full-circle equally spaced pattern, 360/count is the only registered automatic rule. Every independently measurable number, count, dimension, relationship, orientation, material/unit constraint, and single-body requirement must have executable or scoped visual coverage. Do not demand an invented deterministic claim for a verifier gap and do not invent requirements absent from the sources."
if evaluation_context is not None:
instruction += " The evaluation-only known_validation_capability_gaps are authoritative facts explicitly unavailable to the current deterministic verifier registry, not author errors. Do not request an invented executable claim for one of those gaps. When an otherwise covered draft item has a scoped visual acceptance claim for a declared gap, emit verification_gap once; the service will freeze it with an explicit risk and require final independent visual review."
return await self._review_tool(task_id, reviewer, "review_requirements", RequirementsReview, {
"source_index": self.artifacts.read_source_index(task_id), "draft": self._requirements_draft(state), "user_clarifications": self._user_clarifications(task_id),
"evaluation_context": evaluation_context,
"instruction": instruction,
}, schema=self.requirements.review_schema(task_id), feedback=feedback)
async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> CandidateReview | WorkflowError:
candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json")
action = state.pending_action
if not isinstance(candidate, dict) or action is None:
return WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review facts are unavailable.", retryable=True)
claim_ids = [str(item.get("claim_id") or "") for item in candidate.get("claim_results") or () if isinstance(item, dict) and item.get("claim_id")]
return await self._review_tool(task_id, reviewer, "review_candidate", CandidateReview, {
# Candidate review is checkpoint-scoped. The frozen contract is
# enough for this decision; source text is retained only for the
# final review where it guards against a frozen-contract omission.
"requirements_contract": self._requirements_contract(task_id, state),
"candidate_id": state.candidate_id, "working_head": action.working_head,
"action": {"action_id": action.action_id, "intent": action.intent, "expected_change": action.expected_change, "requirement_ids": list(action.requirement_ids)},
"candidate": candidate, "render_manifest": candidate.get("render_manifest") or {},
"instruction": "Independently review only this checkpoint against the supplied action, not as the finished model. Return every provided claim ID exactly once. A deterministic claim with status pending is explicitly deferred to a later action: cover it as pending and still return verdict accept unless this candidate contradicts the current action, violates a global invariant, or the renders show this action itself is wrong. In particular, when the action establishes a flange base, the absence of a later through bore is pending and MUST NOT cause rejection. Deterministic results are evidence and cannot be overridden.",
}, schema=candidate_review_schema(state.candidate_id, action.working_head, claim_ids), feedback=feedback)
async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> FinalReview | WorkflowError:
facts = self.actions._facts(task_id, state.active_revision)
results = self.actions._evaluate_claims(task_id, facts)
return await self._review_tool(task_id, reviewer, "review_final", FinalReview, {
"source_requirements": self.artifacts.read_source_requirements(task_id), "requirements_contract": self._requirements_contract(task_id, state),
"revision_id": state.active_revision, "working_head": state.working_head, "claim_results": results, "render_manifest": (facts.get("report") or {}).get("render_manifest") or {},
"instruction": "Independently review final model evidence. Return every claim ID exactly once. Deterministic results are final gates and cannot be overridden.",
}, schema=final_review_schema(state.working_head, [str(item.get("claim_id") or "") for item in results if item.get("claim_id")]), feedback=feedback)
async def _review_tool(self, task_id: str, reviewer: ModelIdentity, name: str, model_type: type[T], payload: dict[str, Any], *, schema: dict[str, Any] | None = None, feedback: list[dict[str, Any]] | None = None) -> T | WorkflowError:
if feedback:
payload = {**payload, "previous_schema_or_state_error": str(feedback[-1].get("content") or "")[:2_000]}
try:
tool = self._tool(name, schema or model_type)
response = await self.review_gateway.review(kind="requirements" if name == "review_requirements" else "candidate" if name == "review_candidate" else "final", payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id)
except AdapterUnavailable as error:
error_code = (
ErrorCode.RENDER_SERVICE_UNAVAILABLE
if str(error).startswith("RENDER_SERVICE_UNAVAILABLE:")
else ErrorCode.REVIEW_SERVICE_UNAVAILABLE
)
self.repository.record_usage(task_id, {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"usage_available": False,
"context_chars": len(json.dumps(payload, ensure_ascii=False)),
"tool": name,
"provider_id": reviewer.provider_id,
"model_id": reviewer.model_id,
"role": "reviewer",
"retry_reason": error_code.value.lower(),
"cache_hit": False,
})
return WorkflowError(error_code, str(error)[:1000], retryable=True)
check = validate_one_tool_call(response["tool_calls"], name)
if isinstance(check, WorkflowError):
self._record_rejected_tool_calls(
task_id,
actor="reviewer",
expected_tool=name,
tool_calls=response["tool_calls"],
schema=(tool.get("function") or {}).get("parameters"),
)
return check
_tool_name, raw = check
self._record_tool_audit(
task_id,
actor="reviewer",
tool=name,
raw_arguments=raw,
schema=(tool.get("function") or {}).get("parameters"),
)
self.repository.record_usage(task_id, {
**response["usage"],
"context_chars": len(json.dumps(payload, ensure_ascii=False)),
"tool": name,
"provider_id": reviewer.provider_id,
"model_id": reviewer.model_id,
"role": "reviewer",
"raw_arguments_hash": raw_arguments_hash(raw),
"retry_reason": "",
"cache_hit": False,
})
validated = canonical_validate(raw, model_type)
if isinstance(validated, WorkflowError):
return validated
dynamic_error = canonical_validate_schema(raw, schema) if schema is not None else None
return dynamic_error or validated # type: ignore[return-value]
def _record_tool_audit(
self,
task_id: str,
*,
actor: str,
tool: str,
raw_arguments: str,
schema: Any,
returned_tool: str | None = None,
single_allowed_call: bool = True,
) -> None:
"""Persist the raw-output validation and current state-binding facts.
Provider parsers are not a trust boundary. This stores a redacted,
diagnostic copy locally while release reports expose only the hash and
validation/binding summary.
"""
state = self.repository.get_state(task_id)
schema_error = canonical_validate_schema(raw_arguments, schema) if isinstance(schema, dict) else WorkflowError(
ErrorCode.RUNTIME_CONTRACT_INVALID,
"The exposed tool has no JSON Schema.",
)
parsed = canonical_json_object(raw_arguments)
supplied_head = parsed.get("working_head") if isinstance(parsed, dict) else None
topology = self.artifacts.read_topology(task_id, state.active_revision) if state and state.active_revision else None
pending = state.pending_action if state else None
contract_current = True
if pending is not None:
try:
contract_current = self.runtime.operation_contract(pending.atomic_id).get("contract_hash") == pending.contract_hash
except Exception:
contract_current = False
schema_head = self._schema_working_head(schema)
expected_head = schema_head or (state.working_head if state is not None else "")
# Candidate reviews are scoped to the action checkpoint that was used
# to build the candidate. The state has already advanced by the time
# the review runs, so current-state equality would reject a valid,
# schema-bound response. The dynamic schema is server-generated and
# canonical-validated above, making its const the authority here.
working_head_matches = not isinstance(supplied_head, str) or (bool(expected_head) and supplied_head == expected_head)
binding_valid = bool(state) and single_allowed_call and schema_error is None and working_head_matches and contract_current
self.repository.record_tool_audit(task_id, {
"schema_version": "cad.v3.tool-audit.v2",
"actor": actor,
"tool": tool,
"raw_arguments_hash": raw_arguments_hash(raw_arguments),
"redacted_arguments": self._redact_tool_arguments(parsed) if isinstance(parsed, dict) else None,
"canonical_schema_valid": schema_error is None,
"field_errors": list(schema_error.field_errors) if isinstance(schema_error, WorkflowError) else [],
"state_binding": {
"phase": state.phase.value if state else "",
"working_head": state.working_head if state else "",
"expected_working_head": expected_head,
"working_head_binding_source": "schema_const" if schema_head else "current_state",
"supplied_working_head": str(supplied_head or ""),
"working_head_matches": working_head_matches,
"active_revision": state.active_revision if state else "",
"contract_hash": pending.contract_hash if pending else "",
"contract_hash_current": contract_current,
"selector_snapshot_id": str((topology or {}).get("snapshot_id") or ""),
"binding_valid": binding_valid,
},
"returned_tool": returned_tool if returned_tool is not None else tool,
"single_allowed_call": single_allowed_call,
})
@staticmethod
def _schema_working_head(schema: Any) -> str | None:
"""Return a server-bound head from a dynamic top-level tool schema."""
properties = schema.get("properties") if isinstance(schema, dict) else None
working_head = properties.get("working_head") if isinstance(properties, dict) else None
value = working_head.get("const") if isinstance(working_head, dict) else None
return value if isinstance(value, str) else None
def _record_rejected_tool_calls(
self,
task_id: str,
*,
actor: str,
expected_tool: str,
tool_calls: list[dict[str, Any]],
schema: Any,
) -> None:
"""Audit every returned call even when the one-call gate rejects it."""
if not tool_calls:
self._record_tool_audit(
task_id,
actor=actor,
tool=expected_tool,
raw_arguments="",
schema=schema,
returned_tool="",
single_allowed_call=False,
)
return
for call in tool_calls:
function = call.get("function") if isinstance(call, dict) and isinstance(call.get("function"), dict) else {}
name = str(function.get("name") or "")
arguments = function.get("arguments")
raw = arguments if isinstance(arguments, str) else ""
self._record_tool_audit(
task_id,
actor=actor,
tool=expected_tool,
raw_arguments=raw,
schema=schema,
returned_tool=name,
single_allowed_call=False,
)
@staticmethod
def _redact_tool_arguments(value: Any) -> Any:
if isinstance(value, list):
return [WorkflowCoordinator._redact_tool_arguments(item) for item in value]
if not isinstance(value, dict):
return value
sensitive = {"api_key", "authorization", "credential", "credentials", "secret", "token", "password"}
return {
str(key): "[REDACTED]" if str(key).casefold() in sensitive else WorkflowCoordinator._redact_tool_arguments(item)
for key, item in value.items()
}
def _action_tools(self, task_id: str, state: TaskState, observed: set[str] | None = None) -> list[dict[str, Any]]:
action = state.pending_action
if action is None:
return []
contract = self.runtime.operation_contract(action.atomic_id)
selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden")
seen = observed or set()
topology = self.artifacts.read_topology(task_id, state.active_revision)
tokens = self.runtime.selector_tokens(topology)
eligible_tokens = self._selector_tokens_for_contract(contract, tokens)
if selector_shape == "required" and len(eligible_tokens) > 16 and "topology" not in seen:
return [self._tool("inspect_topology", topology_request_schema(state.working_head))]
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
description = "Submit exactly one CDSL feature for the pending action."
if action.atomic_id.startswith("hole_"):
description += (
" Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. "
"It is not a host-face-local offset; the server converts the world point to the selected face frame."
)
if selector_shape == "required":
description += (
" The root payload shape is {\"feature\":{\"atomic_id\":\"...\","
"\"selector_tokens\":[\"opaque topology token\"],\"params\":{...}}}. "
"feature.selector_tokens is required author input: copy one of the opaque tokens from "
"inspect_topology; the server resolves it to the host after validation."
)
fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references))}}
return [fragment]
def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
if not state.repair_required:
return []
if self.actions.rollback_available(task_id, state):
return [self._tool("rollback_checkpoint", rollback_checkpoint_schema(state.working_head, list(self.actions.checkpoint_tokens(task_id, state))))]
if self.actions.repair_action_ready(task_id, state):
return []
evidence_refs = list(self.actions.diagnostic_evidence_refs(task_id, state))
if not evidence_refs:
return []
return [self._tool("record_geometry_conclusion", geometry_conclusion_schema(state.working_head, evidence_refs))]
def _requirements_author_tools(self, task_id: str, draft_schema: dict[str, Any], patch_schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Expose exactly one requirement command from persisted draft facts."""
state = self.repository.get_state(task_id)
draft = self._requirements_draft(state)
review = self._requirements_review(task_id, state)
if isinstance(review, dict) and review.get("decision") == "revise":
return [self._tool("patch_requirements_draft", patch_schema)]
source_ids = set(self.artifacts.read_source_index(task_id))
covered = {
str(source_id)
for item in draft.get("items") or ()
if isinstance(item, dict)
for source_id in item.get("source_ids") or ()
}
if draft.get("items") and source_ids.issubset(covered):
return [self._tool("finalize_requirements_draft", EmptyCommand)]
return [self._tool("submit_requirements_draft_batch", draft_schema)]
def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> list[dict[str, Any]]:
state = self.repository.get_state(task_id)
if state is None:
return []
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
draft = self._requirements_draft(state)
review = self._requirements_review(task_id, state)
if isinstance(review, dict) and review.get("decision") == "revise":
current_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
example_id = current_ids[0] if current_ids else "draft_001"
instruction = (
"The independent reviewer requested revisions. Call patch_requirements_draft with exactly one outer patches array. "
f"Each patch nests target_draft_id (for example {example_id!r}) inside patches[], alongside op. "
"For op=replace, item is the complete replacement RequirementInput and MUST NOT include draft_id; draft_id is server-owned. "
"Do not put target_draft_id or item at the top level. The root object must have this exact shape: "
f'{{"patches":[{{"target_draft_id":"{example_id}","op":"replace","item":{{...}}}}]}}. '
"Then call finalize_requirements_draft for another review. Do not write Markdown."
)
evaluation_context = self.requirements.evaluation_review_context(task_id)
if evaluation_context is not None:
instruction += " For a declared evaluation-only validation capability gap, an existing scoped visual claim is valid coverage. Preserve it and do not add a made-up deterministic substitute solely to address that gap."
else:
covered = {str(source_id) for item in draft.get("items") or () if isinstance(item, dict) for source_id in item.get("source_ids") or ()}
source_ids = set(self.artifacts.read_source_index(task_id))
instruction = "Every source is represented in the draft. Call finalize_requirements_draft now; do not write Markdown." if draft.get("items") and source_ids.issubset(covered) else "Create at most 8 structured requirement items that cover every source. Do not write Markdown."
content = {"protocol": "cad.v3", "phase": state.phase.value, "source_index": self.artifacts.read_source_index(task_id), "source_requirements": self.artifacts.read_source_requirements(task_id), "user_clarifications": self._user_clarifications(task_id), "draft": draft, "review": review, "evaluation_context": self.requirements.evaluation_review_context(task_id), "instruction": instruction}
else:
contract = self._requirements_contract(task_id, state) or {}
compact = [{
"requirement_id": item.get("requirement_id"),
"statement": item.get("statement"),
"acceptance_claims": [
{
"claim_id": claim.get("claim_id"),
"claim_kind": claim.get("claim_kind"),
"expected": claim.get("expected"),
}
for claim in item.get("acceptance_claims") or ()
if isinstance(claim, dict)
],
} for item in contract.get("requirements") or () if isinstance(item, dict)]
action = state.pending_action
operation = self.runtime.operation_contract(action.atomic_id) if action is not None else None
selector_shape = str((operation or {}).get("fragment_shape", {}).get("selector_tokens") or "forbidden")
instruction = "Choose only the next atomically verifiable action or complete when every requirement is proven. The service will not choose CAD operations for you."
if state.repair_required:
instruction = "A prior candidate or final review requires repair. Use the current server evidence to record a geometry conclusion, then either choose a new action or, after a rollback conclusion, request an earlier checkpoint. The service will not choose CAD operations for you."
operation_payload = self._operation_payload(task_id, state) if action is not None else None
selector_summary = []
if action is not None and selector_shape == "required":
tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
allowed = set(self._selector_tokens_for_contract(operation or {}, tokens))
selector_summary = [
{"token": token, "kind": value.get("kind"), "geometry": {key: value.get("geometry", {}).get(key) for key in ("center_mm", "normal", "bbox_mm", "surface_type") if key in value.get("geometry", {})}}
for token, value in tokens.items() if token in allowed
][:16]
instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated."
content = {"protocol": "cad.v3", "phase": state.phase.value, "working_head": state.working_head, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "applied_normalizations": contract.get("applied_normalizations") or [], "claim_coverage": self.actions.claim_summary(task_id, state), "model_summary": self.actions.model_summary(task_id, state), "active_revision": state.active_revision, "pending_action": self._pending_context(state), "operation_contract": operation_payload, "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "recent_ledger": (self.repository.get_task_projection(task_id) or {}).get("action_ledger_summary", [])[-4:], "diagnostic_evidence_refs": list(self.actions.diagnostic_evidence_refs(task_id, state)) if state.repair_required else [], "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
return [*messages, *feedback[-2:]]
def _requirements_draft(self, state: TaskState | None) -> dict[str, Any]:
if state is None:
return {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path)
def _requirements_review(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None:
return self.artifacts.read_requirements_review(task_id, state.requirements_review_path if state is not None else "")
def _user_clarifications(self, task_id: str) -> list[dict[str, str]]:
clarifications: list[dict[str, str]] = []
for event in self.repository.ledger_events(task_id):
if event.get("event") != "user_clarification_received":
continue
path = str(event.get("clarification_path") or "")
payload = self.artifacts.read_json(task_id, path) if path else None
text = str((payload or {}).get("text") or "").strip()
if text:
clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text})
return clarifications
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
"""Expose the persisted requirement question when a task is parked.
The review artifact is the durable source of a human decision. The
terminal event deliberately carries only its explicit questions, not
the full reviewer report, so it remains useful to both SSE clients and
conversation history without leaking unrelated review detail.
"""
review = self._requirements_review(task_id, state) or {}
questions: list[str] = []
unresolved: list[dict[str, str]] = []
for finding in review.get("findings") or ():
if not isinstance(finding, dict):
continue
question = str(finding.get("question") or "").strip()
finding_type = str(finding.get("finding_type") or "")
if finding_type == "ambiguous_conflict" and question and question not in questions:
questions.append(question)
if finding_type == "ambiguous_conflict":
unresolved.append({
"draftId": str(finding.get("draft_id") or ""),
"reasonCode": finding_type,
"question": question,
})
issues = [
str(finding.get("description") or "").strip()
for finding in review.get("findings") or ()
if isinstance(finding, dict) and str(finding.get("description") or "").strip()
]
if not questions:
raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question")
message = f"Requirements need a user decision. {questions[0]}"
payload: dict[str, Any] = {
"taskId": task_id,
"lifecycle": TaskPhase.WAITING_FOR_USER.value.lower(),
"revisionId": state.active_revision,
"code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value,
"message": message,
"questions": questions,
"reviewPath": state.requirements_review_path,
"blockerType": "requirements_ambiguity",
"userActionRequired": True,
}
if issues:
payload["issues"] = issues
if unresolved:
payload["unresolved"] = unresolved
return payload
def _requirements_contract(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None:
if state is None or not state.requirements_contract_path:
return None
return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
projection = self.repository.get_task_projection(task_id) or {}
return {
"taskId": task_id,
"lifecycle": str(projection.get("lifecycle") or state.phase.value.lower()),
"revisionId": state.active_revision,
"code": state.last_error.value if state.last_error else "",
"message": str(projection.get("message") or ("CAD generation completed." if state.phase == TaskPhase.COMPLETED else "CAD generation stopped.")),
"questions": projection.get("questions") or [],
"issues": projection.get("issues") or [],
"blockerType": str(projection.get("blocker_type") or ""),
"userActionRequired": bool(projection.get("user_action_required")),
"verificationStatus": str(projection.get("verification_status") or "verified"),
"verificationWarnings": projection.get("verification_warnings") or [],
"appliedNormalizations": projection.get("applied_normalizations") or [],
}
def _operation_payload(self, task_id: str, state: TaskState) -> dict[str, Any]:
action = state.pending_action
assert action is not None
contract = self.runtime.operation_contract(action.atomic_id)
topology = self.artifacts.read_topology(task_id, state.active_revision)
tokens = self.runtime.selector_tokens(topology)
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references))}
def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
constraints: list[dict[str, Any]] = []
for event in reversed(self.repository.ledger_events(task_id)):
if event.get("checkpoint_revision") != state.active_revision:
continue
if not event.get("normalized_error_code"):
continue
constraints.append({
"atomic_id": str(event.get("atomic_id") or ""),
"normalized_error_code": str(event.get("normalized_error_code") or ""),
"fragment_hash": str(event.get("fragment_hash") or ""),
"prohibited_exact_fingerprint": str(event.get("failure_exact_fingerprint") or ""),
"attempt": int(event.get("attempt") or 1),
"message": str(event.get("message") or event.get("reason") or "")[:360],
})
if len(constraints) == 4:
break
return constraints
@staticmethod
def _selector_tokens_for_contract(contract: dict[str, Any], tokens: dict[str, dict[str, Any]]) -> list[str]:
"""Narrow dynamic selector enums to the current contract's kind."""
if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") != "required":
return []
policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {}
kind = str(policy.get("token_kind") or "")
return [token for token, value in tokens.items() if isinstance(value, dict) and value.get("kind") == kind]
def _topology_payload(self, task_id: str, state: TaskState, kind: str | None, limit: int) -> dict[str, Any]:
tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
values = [{"token": token, "kind": item["kind"], "geometry": {key: item["geometry"].get(key) for key in ("center_mm", "normal", "plane_normal", "bbox_mm", "radius_mm", "surface_type") if key in item["geometry"]}} for token, item in tokens.items() if not kind or item["kind"] == kind]
return {
"working_head": state.working_head,
"coordinate_system": "world_mm",
"tokens": values[:limit],
}
def _can_complete(self, task_id: str, state: TaskState) -> bool:
if not state.active_revision or state.repair_required:
return False
results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision))
return bool(results) and all(item.get("status") == "pass" for item in results if item.get("deterministic"))
def _transport_or_failure(self, task_id: str, state: TaskState, error: WorkflowError, author: ModelIdentity, attempted: set[str]) -> tuple[ModelIdentity, tuple[str, dict[str, Any]] | None]:
if error.code != ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": error.message}])
return author, ("task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": error.message})
attempted.add(author.provider_id)
# The adapter already performs bounded exponential retries for the
# active provider. The workflow permits one, and only one, provider
# failover before parking the durable task for explicit recovery.
fallback = next((candidate for candidate in self.config.author_fallbacks if candidate.provider_id not in attempted), None) if len(attempted) == 1 else None
if fallback is not None:
self.repository.append_outbox(task_id, {"event": "author_provider_failover", "from_provider": author.provider_id, "to_provider": fallback.provider_id})
return fallback, None
waiting = transition(state, "waiting_retry", error=ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE)
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message}])
return author, ("task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message})
def _service_failure(self, task_id: str, state: TaskState, error: WorkflowError) -> tuple[str, dict[str, Any]]:
waiting = transition(state, "waiting_retry", error=error.code)
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": error.code.value, "message": error.message}])
return "task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": error.code.value, "message": error.message}
def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None:
"""Bound no-side-effect model rejections; park real service failures."""
if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID:
# Registry integrity is a deployment defect. Retrying a model with
# the same broken contract cannot repair it and must never consume
# the author-format budget.
failed = transition(state, "failed", error=error.code)
self.repository.compare_and_swap(failed, events=[{
"event": "runtime_contract_invalid",
"tool": name,
"message": error.message,
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": error.code.value,
"message": error.message,
}
if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED:
# A schema-valid fragment can still be impossible on the current
# geometry. This is not an author-format failure: preserve the
# accepted checkpoint, discard only the pending action and let
# the author make a fresh, evidence-backed choice.
if state.phase == TaskPhase.ACTION_PENDING:
action = state.pending_action
atomic_id = str(error.details.get("atomic_id") or (action.atomic_id if action else ""))
checkpoint_revision = str(error.details.get("active_revision") or state.active_revision)
normalized_code = str(error.details.get("normalized_error_code") or error.code.value)
failure_class_fingerprint = sha256(
f"{checkpoint_revision}|{atomic_id}|{normalized_code}".encode("utf-8")
).hexdigest()
prior = [
event for event in self.repository.ledger_events(task_id)
if event.get("failure_class_fingerprint") == failure_class_fingerprint
]
if len(prior) >= 2:
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(failed, events=[{
"event": "no_progress_limit",
"tool": name,
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": error.message,
"checkpoint_revision": checkpoint_revision,
"atomic_id": atomic_id,
"normalized_error_code": normalized_code,
"failure_class_fingerprint": failure_class_fingerprint,
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The same operation failure class made no progress after three attempts; the last checkpoint was preserved.",
"blockerType": "no_progress_limit",
"userActionRequired": False,
"revisionId": state.active_revision,
}
next_state = transition(
state,
"runtime_precondition_rejected",
pending_action=None,
error=ErrorCode.RUNTIME_PRECONDITION_FAILED,
)
self.repository.compare_and_swap(next_state, events=[{
"event": "runtime_precondition_rejected",
"tool": name,
"code": error.code.value,
"message": error.message,
"checkpoint_revision": checkpoint_revision,
"atomic_id": atomic_id,
"fragment_hash": error.details.get("fragment_hash"),
"failure_exact_fingerprint": error.details.get("failure_exact_fingerprint"),
"normalized_error_code": normalized_code,
"failure_class_fingerprint": failure_class_fingerprint,
"attempt": len(prior) + 1,
}])
feedback_error = error
if prior:
feedback_error = WorkflowError(
error.code,
error.message + " A different fragment or operation path is required; do not repeat the proven failure class.",
field_errors=error.field_errors,
details={**error.details, "prohibited_failure_class": failure_class_fingerprint},
)
feedback[:] = [self._feedback(feedback_error)]
return None
# A precondition result outside fragment submission is an invalid
# workflow implementation state, not a provider/service outage.
failed = transition(state, "failed", error=error.code)
self.repository.compare_and_swap(failed, events=[{
"event": "runtime_precondition_rejected",
"tool": name,
"code": error.code.value,
"message": error.message,
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": error.code.value,
"message": error.message,
}
model_rejection_codes = {
ErrorCode.AUTHOR_FORMAT_INVALID,
ErrorCode.AUTHOR_DECISION_REJECTED,
ErrorCode.STALE_WORKING_HEAD,
}
if error.code in model_rejection_codes:
return self._format_failure(task_id, state, name, error, counters, feedback, actor=actor)
return self._service_failure(task_id, state, error)
def _format_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None:
counters[name] = counters.get(name, 0) + 1
feedback[:] = [self._feedback(error)]
if counters[name] < self.config.format_error_limit:
return None
failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT)
self.repository.compare_and_swap(failed, events=[{"event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors)}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": ErrorCode.FAILED_AUTHOR_FORMAT.value,
"message": f"{actor.capitalize()} repeatedly failed the same canonical schema or state contract.",
"tool": name,
"field_errors": list(error.field_errors),
}
@staticmethod
def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]:
parameters = model if isinstance(model, dict) else model.model_json_schema()
return {"type": "function", "function": {"name": name, "description": name.replace("_", " "), "parameters": parameters}}
@staticmethod
def _result_payload(result: Accepted | Rejected | Waiting) -> dict[str, Any]:
if isinstance(result, Accepted):
return result.payload
return result.error.payload()
@staticmethod
def _feedback(error: WorkflowError) -> dict[str, Any]:
return {"role": "user", "content": json.dumps({"schema_or_state_error": error.payload(), "instruction": "Correct only the reported fields and return one allowed tool call."}, ensure_ascii=False)}
@staticmethod
def _pending_context(state: TaskState) -> dict[str, Any] | None:
action = state.pending_action
return {"action_id": action.action_id, "working_head": action.working_head, "intent": action.intent, "requirement_ids": list(action.requirement_ids), "atomic_id": action.atomic_id, "expected_change": action.expected_change, "contract_hash": action.contract_hash} if action else None
@staticmethod
def _tool_feedback(content: str) -> dict[str, Any] | None:
try:
value = json.loads(content)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
@staticmethod
def _event(task_id: str, tool: str, result: dict[str, Any], status: str, usage: dict[str, Any]) -> dict[str, Any]:
return {"taskId": task_id, "tool": tool, "status": status, "result": result, "usage": usage}
@staticmethod
def _invocation_id(task_id: str) -> str:
return f"{task_id}_inv_{secrets.token_hex(8)}"