chore: save current work

This commit is contained in:
2026-09-01 16:37:27 +08:00
parent bc87bd6d2d
commit 97a03c290b
24 changed files with 1380 additions and 5073 deletions
+322 -252
View File
@@ -17,19 +17,20 @@ 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,
CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, NextAction,
RequirementsAuthorOutput, RollbackCheckpoint, StatelessCandidateReview,
StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint,
StatelessTopologyRequest, TopologyRequest,
canonical_json_object, canonical_validate, canonical_validate_schema,
stateless_final_review_schema,
stateless_next_action_schema, stateless_rollback_checkpoint_schema,
raw_arguments_hash, validate_one_tool_call,
)
from app.cad_agent.application.requirements_review import RequirementsCommandHandler
from app.cad_agent.application.requirements 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.domain.state import TaskPhase, TaskState, retry_resume_event, transition
from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository
@@ -46,7 +47,6 @@ class ModelIdentity:
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
@@ -129,11 +129,12 @@ class WorkflowCoordinator:
request: str,
*,
source_blocks: list[dict[str, Any]] | None = None,
image_inputs: list[dict[str, str]] | 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)
self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
return self.repository.create_task(task_id, request)
def resume(self, task_id: str) -> bool:
@@ -158,15 +159,8 @@ class WorkflowCoordinator:
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:
clarification_request = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None
if not isinstance(clarification_request, dict) or not str(clarification_request.get("question") or "").strip():
return False
text = clarification.strip()
if not text:
@@ -182,7 +176,7 @@ class WorkflowCoordinator:
})
except OSError:
return False
resumed = transition(state, "requirements_clarified")
resumed = transition(state, "requirements_clarified", clarification_path="")
return self.repository.compare_and_swap(resumed, events=[{
"event": "user_clarification_received",
"message_id": message_id,
@@ -271,9 +265,42 @@ class WorkflowCoordinator:
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)
image_paths = self.artifacts.source_image_paths(task_id)
if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None:
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
if terminal:
yield terminal
return
call_budget.record_attempt("reviewer")
observation = await self._observe_images(task_id, reviewer, image_paths)
if isinstance(observation, WorkflowError):
if observation.code == ErrorCode.AUTHOR_FORMAT_INVALID:
observation = WorkflowError(
ErrorCode.REVIEW_SERVICE_UNAVAILABLE,
"Image observation did not return the required structured format.",
field_errors=observation.field_errors,
retryable=True,
)
yield self._service_failure(task_id, state, observation)
return
try:
self.artifacts.write_json_once(task_id, "documents/image-observation.json", {
"schema_version": "cad.image-observation.v3",
**observation.model_dump(mode="json"),
})
except OSError as error:
yield self._storage_failure(task_id, str(error))
return
observed_state = transition(state, "image_observed")
self.repository.compare_and_swap(observed_state, events=[{
"event": "image_observation_ready",
"path": "documents/image-observation.json",
"image_count": len(image_paths),
}])
yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"}
continue
spec_schema = self.requirements.spec_schema()
tools = [self._tool("submit_requirements_spec", spec_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
@@ -294,98 +321,33 @@ class WorkflowCoordinator:
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
validation = canonical_validate(raw, RequirementsAuthorOutput)
dynamic_error = canonical_validate_schema(raw, spec_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)
terminal = self._requirements_format_failure(task_id, state, 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)
command = self.requirements.submit_spec(task_id, validation, invocation_id=invocation_id)
if isinstance(command, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
terminal = self._requirements_rejection(task_id, state, 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)
yield "requirements_ready", self._event(task_id, name, self._result_payload(command), "waiting" if isinstance(command, Waiting) else "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)),
)
action_schema = stateless_next_action_schema(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):
@@ -423,11 +385,7 @@ class WorkflowCoordinator:
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
validation = canonical_validate(raw, StatelessGeometryConclusion)
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)
@@ -435,10 +393,17 @@ class WorkflowCoordinator:
yield terminal
return
continue
validation = GeometryConclusion(
working_head=state.working_head,
evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)),
root_cause=validation.root_cause,
decision=validation.decision,
corrective_intent=validation.corrective_intent,
)
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)
rollback_schema = stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state)))
validation = canonical_validate(raw, StatelessRollbackCheckpoint)
dynamic_error = canonical_validate_schema(raw, rollback_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
@@ -449,9 +414,10 @@ class WorkflowCoordinator:
yield terminal
return
continue
validation = RollbackCheckpoint(working_head=state.working_head, checkpoint_token=validation.checkpoint_token, reason=validation.reason)
command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id))
else:
validation = canonical_validate(raw, NextAction)
validation = canonical_validate(raw, StatelessNextAction)
dynamic_error = canonical_validate_schema(raw, action_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
@@ -462,6 +428,13 @@ class WorkflowCoordinator:
yield terminal
return
continue
validation = NextAction(
working_head=state.working_head,
intent=validation.intent,
requirement_ids=requirement_ids,
atomic_id=validation.operation,
expected_change=validation.expected_change,
)
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)
@@ -523,10 +496,7 @@ class WorkflowCoordinator:
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
validation = canonical_validate(raw, StatelessTopologyRequest)
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)
@@ -534,36 +504,9 @@ class WorkflowCoordinator:
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)
payload = self._topology_payload(task_id, state, validation.kind, validation.limit)
observed.add("topology")
yield "tool_call", self._event(task_id, name, payload, "success", usage)
feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:]
continue
fragment = canonical_json_object(raw)
@@ -618,6 +561,29 @@ class WorkflowCoordinator:
continue
yield self._service_failure(task_id, state, review)
return
candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") or {}
action = state.pending_action
if action is None:
yield self._storage_failure(task_id, "Candidate action is unavailable during review.")
return
coverage = []
for item in candidate.get("claim_results") or ():
if not isinstance(item, dict):
continue
status = str(item.get("status") or "pending")
coverage.append({
"claim_id": str(item.get("claim_id") or ""),
"status": status if status in {"pass", "pending", "fail", "not_applicable"} else "fail",
"evidence_refs": [],
})
review = CandidateReview(
candidate_id=state.candidate_id,
working_head=action.working_head,
verdict=review.verdict,
claim_coverage=coverage,
evidence=review.evidence,
issues=review.issues,
)
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")
@@ -631,6 +597,15 @@ class WorkflowCoordinator:
if state.phase == TaskPhase.FINAL_VALIDATION:
recovered = self.actions.recover_final_review(task_id)
if recovered is not None:
if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed":
completed_state = self.repository.get_state(task_id)
if completed_state is not None:
try:
self._ensure_recovered_completion_result(task_id, completed_state)
except OSError as error:
yield self._storage_failure(task_id, str(error))
return
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
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)
@@ -652,6 +627,41 @@ class WorkflowCoordinator:
continue
yield self._service_failure(task_id, state, review)
return
facts = self.actions._facts(task_id, state.active_revision)
claim_results = self.actions._evaluate_claims(task_id, facts)
visual_decisions = iter(review.visual_claims)
coverage = []
for item in claim_results:
if item.get("deterministic"):
status = str(item.get("status") or "fail")
status = status if status in {"pass", "pending", "fail", "not_applicable"} else "fail"
else:
status = next(visual_decisions).status
coverage.append({"claim_id": str(item.get("claim_id") or ""), "status": status, "evidence_refs": []})
stateless_review = review
review = FinalReview(
working_head=state.working_head,
verdict=review.verdict,
claim_coverage=coverage,
evidence=review.evidence,
issues=review.issues,
)
will_complete = (
stateless_review.verdict == "pass"
and all(item.get("status") == "pass" for item in claim_results if item.get("deterministic"))
and all(item.status == "pass" for item in stateless_review.visual_claims)
)
if will_complete:
try:
self.requirements.write_completion_result(
task_id,
state,
claim_results=claim_results,
review=stateless_review.model_dump(mode="json"),
)
except OSError as error:
yield self._storage_failure(task_id, str(error))
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")
@@ -660,6 +670,8 @@ class WorkflowCoordinator:
yield terminal
return
continue
if isinstance(command, Accepted) and command.payload.get("status") == "completed":
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
state = self.repository.get_state(task_id)
@@ -798,50 +810,50 @@ class WorkflowCoordinator:
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:
async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessCandidateReview | 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)
return await self._review_tool(task_id, reviewer, "review_candidate", StatelessCandidateReview, {
"requirements": self._public_requirements(self._requirements_contract(task_id, state)),
"action": {"intent": action.intent, "expected_change": action.expected_change, "operation": action.atomic_id},
"candidate_facts": self._public_candidate(candidate),
"render_manifest": candidate.get("render_manifest") or {},
"instruction": "Review only whether the current checkpoint correctly performs the stated action. Deterministic facts are authoritative. Do not return task, action, candidate, requirement, claim, revision, head, or evidence identifiers.",
}, feedback=feedback)
async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> FinalReview | WorkflowError:
async def _observe_images(self, task_id: str, reviewer: ModelIdentity, image_paths: list[str]) -> ImageObservation | WorkflowError:
return await self._review_tool(task_id, reviewer, "observe_images", ImageObservation, {
"source_requirements": self.artifacts.read_source_requirements(task_id),
"reference_image_paths": image_paths,
"instruction": (
"Inspect every supplied reference image once. Describe visible part geometry, view directions, readable dimensions, holes and profiles, confidence, assumptions, and uncertainties. "
"Do not create CAD operations and do not return attachment or runtime identifiers."
),
})
async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessFinalReview | 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)
visual_claims = [item for item in results if not item.get("deterministic")]
return await self._review_tool(task_id, reviewer, "review_final", StatelessFinalReview, {
"source_requirements": self.artifacts.read_source_requirements(task_id),
"requirements": self._public_requirements(self._requirements_contract(task_id, state)),
"deterministic_results": [self._public_claim_result(item) for item in results if item.get("deterministic")],
"visual_claims": [self._public_claim_result(item) for item in visual_claims],
"render_manifest": (facts.get("report") or {}).get("render_manifest") or {},
"reference_image_paths": self.artifacts.source_image_paths(task_id),
"instruction": "Review the final CAD renders against the original reference images and the ordered visual claims. Return exactly one visual_claims decision for each supplied visual claim, in the same order. Deterministic results are final. Do not return any runtime identifiers.",
}, schema=stateless_final_review_schema(len(visual_claims)), 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)
kind = "image_observation" if name == "observe_images" else "candidate" if name == "review_candidate" else "final"
response = await self.review_gateway.review(kind=kind, payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id)
except AdapterUnavailable as error:
error_code = (
ErrorCode.RENDER_SERVICE_UNAVAILABLE
@@ -1030,7 +1042,7 @@ class WorkflowCoordinator:
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))]
return [self._tool("inspect_topology", StatelessTopologyRequest)]
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_"):
@@ -1052,66 +1064,36 @@ class WorkflowCoordinator:
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))))]
return [self._tool("rollback_checkpoint", stateless_rollback_checkpoint_schema(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)]
return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)]
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}
content = {
"protocol": "cad.v3.spec.v1",
"source_requirements": self.artifacts.read_source_requirements(task_id),
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
"user_clarifications": self._user_clarifications(task_id),
"instruction": (
"Return one complete bounded requirements specification. Use deterministic claims only when the supplied registry can execute them; otherwise use a scoped visual claim. "
"Unspecified design choices are assumptions and must not block generation. Return clarification only when two quoted source statements cannot both be followed and the user must choose; ask exactly one question. "
"Never return source, task, draft, requirement, claim, revision, candidate, action, head, or evidence identifiers. Do not solve semantic conflicts by changing a user value."
),
}
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"),
}
@@ -1135,18 +1117,10 @@ class WorkflowCoordinator:
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}
content = {"protocol": "cad.v3", "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(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), "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):
@@ -1162,32 +1136,12 @@ class WorkflowCoordinator:
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.
The clarification artifact is the durable source of the single human
decision needed to continue this task.
"""
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()
]
clarification = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None
question = str((clarification or {}).get("question") or "").strip()
questions = [question] if question else []
if not questions:
raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question")
message = f"Requirements need a user decision. {questions[0]}"
@@ -1198,14 +1152,10 @@ class WorkflowCoordinator:
"code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value,
"message": message,
"questions": questions,
"reviewPath": state.requirements_review_path,
"clarificationPath": state.clarification_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:
@@ -1213,6 +1163,85 @@ class WorkflowCoordinator:
return None
return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
def _ensure_recovered_completion_result(self, task_id: str, state: TaskState) -> None:
result_path = self.artifacts.artifact_path(task_id, "completion-result.md")
if result_path.is_file():
return
facts = self.actions._facts(task_id, state.active_revision)
claim_results = self.actions._evaluate_claims(task_id, facts)
raw_review = self.artifacts.read_json(task_id, f"reviews/final/{state.active_revision}/final-review.json") or {}
coverage = {
str(item.get("claim_id") or ""): item
for item in raw_review.get("claim_coverage") or ()
if isinstance(item, dict)
}
visual_claims = [
{
"status": str(coverage.get(str(item.get("claim_id") or ""), {}).get("status") or "fail"),
"evidence": "; ".join(str(value) for value in raw_review.get("evidence") or ()) or "Recovered final review decision.",
}
for item in claim_results
if not item.get("deterministic")
]
self.requirements.write_completion_result(
task_id,
state,
claim_results=claim_results,
review={"visual_claims": visual_claims},
)
@staticmethod
def _public_requirements(contract: dict[str, Any] | None) -> list[dict[str, Any]]:
return [
{
"statement": str(item.get("statement") or ""),
"assumptions": list(item.get("assumptions") or []),
"acceptance_claims": [
{
"claim_kind": str(claim.get("claim_kind") or ""),
"expected": claim.get("expected") or {},
"verification_mode": str(claim.get("verification_mode") or ""),
}
for claim in item.get("acceptance_claims") or ()
if isinstance(claim, dict)
],
}
for item in (contract or {}).get("requirements") or ()
if isinstance(item, dict)
]
@staticmethod
def _public_claim_result(item: dict[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in item.items()
if key not in {"claim_id", "requirement_id", "evidence_refs"}
}
@classmethod
def _public_candidate(cls, candidate: dict[str, Any]) -> dict[str, Any]:
return {
"claim_results": [cls._public_claim_result(item) for item in candidate.get("claim_results") or () if isinstance(item, dict)],
"operation_verifier_results": [cls._public_claim_result(item) for item in candidate.get("operation_verifier_results") or () if isinstance(item, dict)],
"health": candidate.get("health") or {},
"model_summary": candidate.get("model_summary") or {},
}
@staticmethod
def _public_pending_context(state: TaskState) -> dict[str, Any] | None:
action = state.pending_action
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
@staticmethod
def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(payload, dict):
return None
return {
"operation": payload.get("atomic_id"),
"contract": payload.get("contract"),
"fragment_schema": payload.get("fragment_schema"),
}
def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
projection = self.repository.get_task_projection(task_id) or {}
return {
@@ -1227,7 +1256,6 @@ class WorkflowCoordinator:
"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]:
@@ -1429,6 +1457,48 @@ class WorkflowCoordinator:
"field_errors": list(error.field_errors),
}
def _requirements_format_failure(
self,
task_id: str,
state: TaskState,
error: WorkflowError,
counters: dict[str, int],
feedback: list[dict[str, Any]],
) -> tuple[str, dict[str, Any]] | None:
key = "requirements_spec"
counters[key] = counters.get(key, 0) + 1
feedback[:] = [self._feedback(error)]
if counters[key] < 2:
return None
failed = transition(state, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID)
self.repository.compare_and_swap(failed, events=[{
"event": "requirements_spec_invalid",
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
"message": error.message,
"field_errors": list(error.field_errors),
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
"message": "Requirements specification remained unreadable after one field-level correction.",
"tool": "submit_requirements_spec",
"field_errors": list(error.field_errors),
"userActionRequired": False,
}
def _requirements_rejection(
self,
task_id: str,
state: TaskState,
error: WorkflowError,
counters: dict[str, int],
feedback: list[dict[str, Any]],
) -> tuple[str, dict[str, Any]] | None:
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
return self._service_failure(task_id, state, error)
return self._requirements_format_failure(task_id, state, error, counters, feedback)
@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()