添加cadfs_to_cdsl代码,优化cad生成
This commit is contained in:
@@ -7,6 +7,7 @@ import json
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
CandidateReview,
|
||||
FinalReview,
|
||||
@@ -55,6 +56,236 @@ class ActionCommandHandler:
|
||||
available.append(atomic_id)
|
||||
return tuple(available)
|
||||
|
||||
def schedule_next_feature(self, task_id: str) -> Accepted | Rejected:
|
||||
"""Select the server-owned next ready node by fixed plan priority."""
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.SCHEDULING_FEATURE:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Feature scheduling is not expected in the current workflow state."))
|
||||
plan = self._feature_plan(task_id, state)
|
||||
if plan is None:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan is unavailable.", retryable=True))
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
if scheduler.all_done():
|
||||
claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision))
|
||||
deterministic_failures = [
|
||||
item for item in claim_results
|
||||
if item.get("deterministic") and item.get("status") != "pass"
|
||||
]
|
||||
if deterministic_failures:
|
||||
next_state = transition(
|
||||
state,
|
||||
"feature_replan",
|
||||
error=ErrorCode.CLAIM_VERIFICATION_FAILED,
|
||||
)
|
||||
result = {
|
||||
"status": "replan_required",
|
||||
"phase": next_state.phase.value,
|
||||
"claim_results": claim_results,
|
||||
}
|
||||
if not self.repository.compare_and_swap(next_state, events=[{
|
||||
"event": "feature_plan_completion_failed",
|
||||
"plan_hash": state.feature_plan_hash,
|
||||
"revision_id": state.active_revision,
|
||||
"claim_results": claim_results,
|
||||
"failed_claim_ids": [str(item.get("claim_id") or "") for item in deterministic_failures],
|
||||
"message": "All feature nodes completed, but final deterministic validation failed.",
|
||||
}]):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
next_state = transition(state, "final_requested")
|
||||
result = {"status": "final_validation", "phase": next_state.phase.value, "claim_results": claim_results}
|
||||
if not self.repository.compare_and_swap(next_state, events=[{
|
||||
"event": "feature_plan_complete",
|
||||
"plan_hash": state.feature_plan_hash,
|
||||
"revision_id": state.active_revision,
|
||||
"claim_results": claim_results,
|
||||
}], invocation_id=None, invocation_result=None):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
node = scheduler.next_ready()
|
||||
if node is None:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan has no runnable node; revise its unresolved subgraph."))
|
||||
contract = self.runtime.operation_contract(node.atomic_id)
|
||||
requirement_ids = tuple(sorted(self._requirement_ids_for_claims(task_id, tuple(node.claim_ids))))
|
||||
action_id = "feature_" + sha256(f"{state.feature_plan_hash}|{node.node_id}|{state.active_revision}".encode("utf-8")).hexdigest()[:16]
|
||||
pending = PendingAction(
|
||||
action_id=action_id,
|
||||
working_head=state.working_head,
|
||||
intent=node.intent,
|
||||
requirement_ids=requirement_ids,
|
||||
atomic_id=node.atomic_id,
|
||||
expected_change=node.expected_change,
|
||||
contract_hash=contract["contract_hash"],
|
||||
idempotency_key=sha256(f"{task_id}|{action_id}|{state.working_head}".encode("utf-8")).hexdigest(),
|
||||
node_id=node.node_id,
|
||||
plan_hash=state.feature_plan_hash,
|
||||
claim_ids=tuple(node.claim_ids),
|
||||
depends_on_node_ids=tuple(node.depends_on),
|
||||
)
|
||||
next_state = transition(state, "feature_scheduled", pending_action=pending)
|
||||
event = {
|
||||
"event": "feature_node_scheduled", "node_id": node.node_id, "node_hash": node_hash(node),
|
||||
"plan_hash": state.feature_plan_hash, "action_id": action_id, "atomic_id": node.atomic_id,
|
||||
"depends_on": node.depends_on, "claim_ids": node.claim_ids, "priority": node.priority,
|
||||
}
|
||||
if not self.repository.compare_and_swap(next_state, events=[event]):
|
||||
return Rejected(self._stale())
|
||||
return Accepted({"status": "scheduled", "node_id": node.node_id, "atomic_id": node.atomic_id, "phase": next_state.phase.value})
|
||||
|
||||
def submit_feature_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected:
|
||||
state = self.repository.get_state(task_id)
|
||||
action = state.pending_feature if state else None
|
||||
if state is None or state.phase != TaskPhase.FEATURE_PENDING or action is None:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature fragment requires one scheduled feature node."))
|
||||
plan = self._feature_plan(task_id, state)
|
||||
if plan is None or action.plan_hash != state.feature_plan_hash:
|
||||
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature belongs to an obsolete plan."))
|
||||
node = next((item for item in plan.nodes if item.node_id == action.node_id), None)
|
||||
if node is None or node.atomic_id != action.atomic_id or tuple(node.claim_ids) != action.claim_ids:
|
||||
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature no longer matches the active plan."))
|
||||
contract = self.runtime.operation_contract(action.atomic_id)
|
||||
if contract["contract_hash"] != action.contract_hash:
|
||||
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The operation contract changed while the feature was scheduled."))
|
||||
base = self.artifacts.read_active_cdsl(task_id, state.active_revision)
|
||||
selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
|
||||
references = self.runtime.reference_tokens(base)
|
||||
selector_policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {}
|
||||
selector_kind = str(selector_policy.get("token_kind") or "")
|
||||
allowed_selectors = [
|
||||
token for token, value in selectors.items()
|
||||
if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required"
|
||||
and isinstance(value, dict) and value.get("kind") == selector_kind
|
||||
]
|
||||
errors = validate_fragment(contract, fragment, selector_tokens=allowed_selectors, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))
|
||||
if errors:
|
||||
return self._feature_failure(task_id, state, action, ErrorCode.AUTHOR_FORMAT_INVALID, "fragment_preflight", "CDSL fragment violates the scheduled operation schema.", invocation_id=invocation_id, field_errors=tuple(errors))
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
feature_ids = scheduler.feature_ids()
|
||||
direct_feature_ids = tuple(feature_ids.get(dependency, "") for dependency in action.depends_on_node_ids)
|
||||
if any(not value for value in direct_feature_ids):
|
||||
return self._feature_failure(task_id, state, action, ErrorCode.RUNTIME_PRECONDITION_FAILED, "dependency", "A direct dependency has no verified runtime feature.", invocation_id=invocation_id)
|
||||
fragment_hash = canonical_hash(fragment)
|
||||
key = self._key(task_id, "feature_fragment", state.working_head, {"node_id": action.node_id, "fragment": fragment})
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, key)
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return Accepted(invocation.result)
|
||||
try:
|
||||
cdsl, audit = self.runtime.materialize_fragment(
|
||||
base, fragment, contract, selectors, references,
|
||||
require_through=self._claims_require_through(task_id, action.claim_ids),
|
||||
depends_on_feature_ids=direct_feature_ids,
|
||||
)
|
||||
except Exception as error:
|
||||
return self._feature_failure(task_id, state, action, self._runtime_error(error).code, "fragment_preflight", str(error), invocation_id=invocation_id, invocation=invocation)
|
||||
stage_id = "feature_stage_" + sha256(key.encode("utf-8")).hexdigest()[:16]
|
||||
try:
|
||||
stage = self.artifacts.start_candidate_stage(task_id, key, {
|
||||
"schema_version": "cad.v3.2.feature-input.v1", "stage_id": stage_id,
|
||||
"node_id": action.node_id, "node_hash": node_hash(node), "plan_hash": state.feature_plan_hash,
|
||||
"action_id": action.action_id, "fragment": fragment, "fragment_audit": audit,
|
||||
})
|
||||
except OSError as error:
|
||||
return self._park_retry(state, ErrorCode.STORAGE_FAILURE, event="feature_stage_storage_failure", message=str(error), details={"node_id": action.node_id})
|
||||
building = transition(state, "feature_started", feature_stage_id=stage.stage_id)
|
||||
if not self.repository.compare_and_swap(building, events=[{
|
||||
"event": "feature_node_building", "node_id": action.node_id, "node_hash": node_hash(node),
|
||||
"plan_hash": state.feature_plan_hash, "stage_id": stage.stage_id, "fragment_hash": fragment_hash,
|
||||
}]):
|
||||
return Rejected(self._stale())
|
||||
try:
|
||||
rebuilt = self.runtime.build_checkpoint(cdsl, stage.output_dir, task_id, stage_id)
|
||||
try:
|
||||
preview = self.runtime.create_preview(stage.output_dir)
|
||||
rebuilt["paths"]["glb"] = preview["path"]
|
||||
except Exception as preview_error:
|
||||
preview = {"preview_unavailable": str(preview_error)[:500]}
|
||||
claim_results = self._evaluate_claims(task_id, rebuilt, claim_ids=set(action.claim_ids))
|
||||
operation_results = self._operation_candidate_results(
|
||||
action, contract, cdsl, rebuilt, parent_facts=self._facts(task_id, state.active_revision),
|
||||
require_through=self._claims_require_through(task_id, action.claim_ids),
|
||||
)
|
||||
global_failures = [item for item in self._evaluate_claims(task_id, rebuilt) if item.get("claim_kind") in {"solid_count_equals", "single_connected_body"} and item.get("status") != "pass"]
|
||||
blockers = [
|
||||
# A claim is assigned to exactly one runtime-atomic node in
|
||||
# the Feature Plan. Unlike unassigned future-work claims,
|
||||
# an assigned claim may not remain pending when that node is
|
||||
# published: doing so would turn a missing feature into a
|
||||
# permanent, apparently successful checkpoint.
|
||||
*[item for item in claim_results if item.get("status") != "pass"],
|
||||
*[item for item in operation_results if item.get("status") != "pass"],
|
||||
*global_failures,
|
||||
]
|
||||
if blockers:
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", {
|
||||
"schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id,
|
||||
"claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers,
|
||||
})
|
||||
return self._feature_failure(task_id, building, action, ErrorCode.CLAIM_VERIFICATION_FAILED, "node_validation", "The feature did not satisfy its local deterministic acceptance.", invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id, "blockers": blockers})
|
||||
verification = {
|
||||
"schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id,
|
||||
"node_hash": node_hash(node), "plan_hash": state.feature_plan_hash,
|
||||
"feature_id": audit["assigned_feature_ids"][0], "claim_results": claim_results,
|
||||
"operation_verifier_results": operation_results, "health": rebuilt["health"], "preview": preview,
|
||||
}
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", verification)
|
||||
revision_id = self._next_revision(task_id)
|
||||
paths = self.artifacts.publish_candidate(task_id, stage.stage_id, revision_id)
|
||||
next_state = transition(building, "feature_verified", active_revision=revision_id)
|
||||
result = {"status": "verified", "node_id": action.node_id, "revision_id": revision_id, "paths": paths, "claim_results": claim_results}
|
||||
event = {
|
||||
"event": "feature_node_verified", "node_id": action.node_id, "node_hash": node_hash(node),
|
||||
"plan_hash": state.feature_plan_hash, "feature_id": audit["assigned_feature_ids"][0],
|
||||
"atomic_id": action.atomic_id,
|
||||
"revision_id": revision_id, "parent_revision": state.active_revision, "stage_id": stage.stage_id,
|
||||
"claim_results": claim_results, "preview_unavailable": preview.get("preview_unavailable", ""),
|
||||
}
|
||||
if not self._commit_invocation(next_state, [event], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
except OSError as error:
|
||||
return self._park_retry(building, ErrorCode.STORAGE_FAILURE, event="feature_build_storage_failure", message=str(error), details={"node_id": action.node_id, "stage_id": stage.stage_id})
|
||||
except Exception as error:
|
||||
return self._feature_failure(task_id, building, action, self._runtime_error(error).code, "engine_build", str(error), invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id})
|
||||
|
||||
def recover_feature_build(self, task_id: str) -> Accepted | Rejected:
|
||||
"""Make a crashed build retryable without losing its scheduled node."""
|
||||
state = self.repository.get_state(task_id)
|
||||
action = state.pending_feature if state else None
|
||||
if state is None or state.phase != TaskPhase.FEATURE_BUILDING or action is None:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "There is no recoverable feature build."))
|
||||
next_state = transition(state, "feature_retry")
|
||||
if not self.repository.compare_and_swap(next_state, events=[{
|
||||
"event": "feature_build_recovered", "node_id": action.node_id, "plan_hash": action.plan_hash,
|
||||
"stage_id": state.feature_stage_id, "message": "Interrupted node build returned to the same scheduled feature.",
|
||||
}]):
|
||||
return Rejected(self._stale())
|
||||
return Accepted({"status": "retry", "node_id": action.node_id, "phase": next_state.phase.value})
|
||||
|
||||
def _feature_failure(self, task_id: str, state: TaskState, action: PendingAction, code: ErrorCode, failure_class: str, message: str, *, invocation_id: str, field_errors: tuple[dict[str, Any], ...] = (), invocation: Any = None, details: dict[str, Any] | None = None) -> Rejected:
|
||||
plan = self._feature_plan(task_id, state)
|
||||
node = next((item for item in (plan.nodes if plan else []) if item.node_id == action.node_id), None)
|
||||
if node is None:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature node is unavailable.", retryable=True))
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
attempt = scheduler.failure_count(action.node_id, failure_class) + 1
|
||||
terminal = attempt >= 2
|
||||
event = {
|
||||
"event": "feature_node_failed", "node_id": action.node_id, "node_hash": node_hash(node),
|
||||
"plan_hash": state.feature_plan_hash, "failure_class": failure_class, "attempt": attempt,
|
||||
"terminal": terminal, "code": code.value, "normalized_error_code": code.value,
|
||||
"atomic_id": action.atomic_id, "checkpoint_revision": state.active_revision,
|
||||
"message": message[:1000], **(details or {}),
|
||||
}
|
||||
next_state = transition(state, "feature_replan" if terminal else "feature_retry", error=code)
|
||||
result = {"status": "replan_required" if terminal else "retry", "node_id": action.node_id, "attempt": attempt, "failure_class": failure_class}
|
||||
if invocation is not None:
|
||||
committed = self._commit_invocation(next_state, [event], invocation, result)
|
||||
else:
|
||||
committed = self.repository.compare_and_swap(next_state, events=[event])
|
||||
if not committed:
|
||||
return Rejected(self._stale())
|
||||
return Rejected(WorkflowError(code, message, field_errors=field_errors, details={**(details or {}), "node_id": action.node_id, "attempt": attempt, "replan_required": terminal}))
|
||||
|
||||
def propose_next_action(self, task_id: str, proposal: NextAction, *, invocation_id: str) -> Accepted | Rejected:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None:
|
||||
@@ -388,6 +619,8 @@ class ActionCommandHandler:
|
||||
|
||||
def submit_cdsl_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is not None and state.phase == TaskPhase.FEATURE_PENDING:
|
||||
return self.submit_feature_fragment(task_id, fragment, invocation_id=invocation_id)
|
||||
action = state.pending_action if state else None
|
||||
if state is None or state.phase != TaskPhase.ACTION_PENDING or action is None:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A CDSL fragment requires one pending action."))
|
||||
@@ -922,11 +1155,18 @@ class ActionCommandHandler:
|
||||
"atomic_id": atomic_id,
|
||||
"fragment_hash": accepted_fragment,
|
||||
}) if accepted_fragment and atomic_id else ""
|
||||
next_state = transition(state, "final_repair", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED)
|
||||
is_dag = bool(state.feature_plan_hash)
|
||||
next_state = transition(
|
||||
state,
|
||||
"feature_replan" if is_dag else "final_repair",
|
||||
repair_required=not is_dag,
|
||||
error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED,
|
||||
)
|
||||
result = {"status": "repair", "revision_id": state.active_revision}
|
||||
if not self._commit_invocation(next_state, [{
|
||||
"event": "final_review_repair",
|
||||
"event": "final_visual_reviewed" if is_dag else "final_review_repair",
|
||||
"revision_id": state.active_revision,
|
||||
**({"plan_hash": state.feature_plan_hash} if is_dag else {}),
|
||||
"claim_results": claim_results,
|
||||
"review_claim_coverage": [item.model_dump(mode="json") for item in review.claim_coverage],
|
||||
"visual_not_passed": [str(item.get("claim_id") or "") for item in visual_not_passed],
|
||||
@@ -947,15 +1187,48 @@ class ActionCommandHandler:
|
||||
return Accepted(result)
|
||||
next_state = transition(state, "final_accepted")
|
||||
result = {"status": "completed", "revision_id": state.active_revision}
|
||||
if not self._commit_invocation(next_state, [{"event": "completed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
|
||||
if not self._commit_invocation(next_state, [{"event": "final_visual_reviewed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
|
||||
def _evaluate_claims(self, task_id: str, facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def _evaluate_claims(self, task_id: str, facts: dict[str, Any], *, claim_ids: set[str] | None = None) -> list[dict[str, Any]]:
|
||||
contract = self._requirements_contract(task_id) or {}
|
||||
claims = [claim for requirement in contract.get("requirements") or () if isinstance(requirement, dict) for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
|
||||
claims = [
|
||||
claim for requirement in contract.get("requirements") or () if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict) and (claim_ids is None or str(claim.get("claim_id") or "") in claim_ids)
|
||||
]
|
||||
return self.verifiers.evaluate(claims, facts)
|
||||
|
||||
def _feature_plan(self, task_id: str, state: TaskState | None = None) -> FeaturePlan | None:
|
||||
state = state or self.repository.get_state(task_id)
|
||||
if state is None or not state.feature_plan_path:
|
||||
return None
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
return FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _requirement_ids_for_claims(self, task_id: str, claim_ids: tuple[str, ...]) -> set[str]:
|
||||
wanted = set(claim_ids)
|
||||
contract = self._requirements_contract(task_id) or {}
|
||||
return {
|
||||
str(requirement.get("requirement_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
and any(str(claim.get("claim_id") or "") in wanted for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict))
|
||||
}
|
||||
|
||||
def _claims_require_through(self, task_id: str, claim_ids: tuple[str, ...]) -> bool:
|
||||
wanted = set(claim_ids)
|
||||
contract = self._requirements_contract(task_id) or {}
|
||||
return any(
|
||||
str(claim.get("claim_id") or "") in wanted and claim.get("claim_kind") == "through_cylindrical_bore"
|
||||
for requirement in contract.get("requirements") or () if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)
|
||||
)
|
||||
|
||||
def claim_summary(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
"""Return bounded current claim facts for author context assembly."""
|
||||
return [
|
||||
@@ -1059,7 +1332,16 @@ class ActionCommandHandler:
|
||||
continue
|
||||
expected: dict[str, Any]
|
||||
if claim_kind in {"cylindrical_bore", "through_cylindrical_bore"}:
|
||||
diameter = params.get("diameter_mm")
|
||||
# A counterbore can reuse an already-existing pilot bore. In
|
||||
# that case the material change is the larger cylindrical
|
||||
# recess, not an additional instance of the pilot diameter.
|
||||
# Measuring the pilot would count the parent bore as a new
|
||||
# feature and make a valid counterbore checkpoint fail.
|
||||
diameter = (
|
||||
params.get("counterbore_diameter_mm")
|
||||
if action.atomic_id == "hole_counterbore" and claim_kind == "cylindrical_bore"
|
||||
else params.get("diameter_mm")
|
||||
)
|
||||
positions = params.get("positions")
|
||||
if not isinstance(diameter, (int, float)):
|
||||
return [{"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": claim_kind, "deterministic": True, "status": "unavailable", "evidence": {"reason": "operation has no measurable bore diameter"}}]
|
||||
@@ -1152,7 +1434,7 @@ class ActionCommandHandler:
|
||||
values = [
|
||||
int(str(event.get("revision_id") or "").removeprefix("rev_"))
|
||||
for event in self.repository.ledger_events(task_id)
|
||||
if event.get("event") == "accepted"
|
||||
if event.get("event") in {"accepted", "feature_node_verified"}
|
||||
and str(event.get("revision_id") or "").startswith("rev_")
|
||||
and str(event.get("revision_id") or "").removeprefix("rev_").isdigit()
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.cad_agent.application.llm_contracts import (
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema,
|
||||
)
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.ports import CadRuntime, ModelGateway
|
||||
@@ -30,6 +31,8 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
|
||||
if role == "reviewer":
|
||||
return [
|
||||
_tool("observe_images", ImageObservation.model_json_schema()),
|
||||
# Compatibility conformance probe; regular DAG execution never
|
||||
# calls a per-node reviewer.
|
||||
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
|
||||
_tool("review_final", stateless_final_review_schema(1)),
|
||||
]
|
||||
@@ -40,8 +43,9 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
|
||||
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
|
||||
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
|
||||
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
|
||||
_tool("write_feature_plan", FeaturePlan.model_json_schema()),
|
||||
# Compatibility probe only; production v3.2 workflow never exposes it.
|
||||
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
|
||||
_tool("propose_next_action", stateless_next_action_schema(atomic_ids)),
|
||||
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
|
||||
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
|
||||
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
|
||||
@@ -57,7 +61,7 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
|
||||
|
||||
|
||||
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
|
||||
payload = {"protocol": "cad.v3.1.markdown-first", "role": role, "tools": tools}
|
||||
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
from app.cad_agent.application.results import Accepted, Rejected
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
|
||||
@@ -18,6 +19,9 @@ from app.cad_agent.ports import ArtifactStore, TaskRepository
|
||||
|
||||
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
|
||||
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
|
||||
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
|
||||
_BORE_MARKERS = ("bore", "hole", "孔")
|
||||
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
|
||||
|
||||
|
||||
class RequirementsCommandHandler:
|
||||
@@ -27,10 +31,11 @@ class RequirementsCommandHandler:
|
||||
service derives those values strictly from the immutable checklist.
|
||||
"""
|
||||
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
|
||||
self.repository = repository
|
||||
self.artifacts = artifacts
|
||||
self.registry = registry
|
||||
self.atomic_ids = atomic_ids or (lambda: ())
|
||||
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
||||
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
@@ -59,6 +64,81 @@ class RequirementsCommandHandler:
|
||||
len(self._checklist_items(task_id)),
|
||||
)
|
||||
|
||||
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
|
||||
"""Return the plan tool schema bound to the persisted planning state.
|
||||
|
||||
A model is allowed to choose node content, but it must not guess the
|
||||
immutable lineage identifiers of a plan revision. Binding those values
|
||||
as enums prevents a requirements-contract hash (or a stale plan hash)
|
||||
from being mistaken for ``parent_plan_hash``.
|
||||
"""
|
||||
schema = FeaturePlan.model_json_schema()
|
||||
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
||||
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
|
||||
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
|
||||
node_properties = node.get("properties") if isinstance(node, dict) else None
|
||||
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
|
||||
if isinstance(atomic, dict):
|
||||
atomic["enum"] = list(self.atomic_ids())
|
||||
state = self.repository.get_state(task_id)
|
||||
previous: FeaturePlan | None = None
|
||||
if state is not None and state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
previous = None
|
||||
parent_hash = plan_hash(previous) if previous is not None else ""
|
||||
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
|
||||
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
|
||||
if isinstance(parent, dict):
|
||||
parent["enum"] = [parent_hash]
|
||||
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(replaced, dict):
|
||||
replaced.update({
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
"minItems": len(replacements),
|
||||
"maxItems": len(replacements),
|
||||
"items": {"enum": replacements},
|
||||
})
|
||||
contract = self.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
state.requirements_contract_path if state is not None else "",
|
||||
) or {}
|
||||
deterministic_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") == "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
visual_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
|
||||
if isinstance(node_claim_ids, dict):
|
||||
# Authors occasionally repeat a visual claim on the node that
|
||||
# creates the feature as well as in final_claim_ids. Accept that
|
||||
# harmless reference at the tool boundary; submit_feature_plan()
|
||||
# removes it before the immutable DAG is validated and written.
|
||||
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
|
||||
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(final_claim_ids, dict):
|
||||
final_claim_ids["items"] = {"enum": visual_claim_ids}
|
||||
final_claim_ids["maxItems"] = len(visual_claim_ids)
|
||||
return schema
|
||||
|
||||
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
|
||||
|
||||
@@ -66,7 +146,157 @@ class RequirementsCommandHandler:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
|
||||
|
||||
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_MODELING_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
||||
# Compatibility shim for callers compiled against v3.1. The v3.2
|
||||
# coordinator never offers this method to an LLM.
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
||||
|
||||
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
|
||||
"""Freeze a validated initial plan or full subgraph plan revision."""
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||
if not isinstance(contract, dict):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
|
||||
plan = self._normalize_feature_plan_visual_references(plan, contract)
|
||||
plan = self._assign_unowned_global_health_claims(plan, contract)
|
||||
previous: FeaturePlan | None = None
|
||||
completed: dict[str, str] = {}
|
||||
if state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
|
||||
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
|
||||
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
|
||||
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
|
||||
digest = plan_hash(plan)
|
||||
path = f"plans/feature-plan-{digest}.json"
|
||||
event = "feature_plan_written" if previous is None else "feature_plan_revised"
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
|
||||
events: list[dict[str, Any]] = [{
|
||||
"event": event,
|
||||
"invocation_id": invocation_id,
|
||||
"plan_path": written,
|
||||
"plan_hash": digest,
|
||||
"parent_plan_hash": plan.parent_plan_hash,
|
||||
"replaces_node_ids": plan.replaces_node_ids,
|
||||
}]
|
||||
if previous is not None:
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
old_hash = plan_hash(previous)
|
||||
events.extend({
|
||||
"event": "feature_node_invalidated",
|
||||
"node_id": node_id,
|
||||
"node_hash": node_hash(old_nodes[node_id]),
|
||||
"plan_hash": old_hash,
|
||||
"replacement_plan_hash": digest,
|
||||
} for node_id in plan.replaces_node_ids)
|
||||
if not self._commit(next_state, events, invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Drop non-owning visual references from feature nodes.
|
||||
|
||||
A node's ``claim_ids`` drive synchronous deterministic acceptance.
|
||||
Visual claims are owned solely by ``final_claim_ids`` and have no
|
||||
node-local verifier. Retaining a repeated visual ID therefore adds
|
||||
no behavior and turns an otherwise valid plan into a schema retry.
|
||||
The contract validation below still requires every visual claim to be
|
||||
present exactly once in ``final_claim_ids``.
|
||||
"""
|
||||
visual_claim_ids = {
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
if not visual_claim_ids or not any(
|
||||
claim_id in visual_claim_ids
|
||||
for node in plan.nodes
|
||||
for claim_id in node.claim_ids
|
||||
):
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
for node in normalized.nodes:
|
||||
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Bind global solid-health claims to the unique root body feature.
|
||||
|
||||
``single_connected_body`` and ``solid_count_equals`` are checked as
|
||||
global health on every feature checkpoint. When a plan has exactly
|
||||
one root additive feature, their node owner is consequently
|
||||
determined without choosing any geometry strategy. This prevents an
|
||||
otherwise complete plan from failing merely because an author omitted
|
||||
the redundant ownership annotation.
|
||||
"""
|
||||
claims = {
|
||||
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
|
||||
unowned = [
|
||||
claim_id for claim_id, claim_kind in claims.items()
|
||||
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
|
||||
]
|
||||
roots = [
|
||||
node for node in plan.nodes
|
||||
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
|
||||
]
|
||||
if not unowned or len(roots) != 1:
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
root_id = roots[0].node_id
|
||||
for node in normalized.nodes:
|
||||
if node.node_id == root_id:
|
||||
node.claim_ids = [*node.claim_ids, *unowned]
|
||||
break
|
||||
return normalized
|
||||
|
||||
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
statuses = scheduler.statuses()
|
||||
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
|
||||
if not failed:
|
||||
return set()
|
||||
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
|
||||
for node in plan.nodes:
|
||||
for dependency in node.depends_on:
|
||||
children.setdefault(dependency, set()).add(node.node_id)
|
||||
result = set(failed)
|
||||
pending = list(failed)
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
for child in children.get(current, set()):
|
||||
if statuses.get(child) != "done" and child not in result:
|
||||
result.add(child)
|
||||
pending.append(child)
|
||||
return result
|
||||
|
||||
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
@@ -79,7 +309,10 @@ class RequirementsCommandHandler:
|
||||
if len(output.requirements) != len(targets):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
|
||||
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
|
||||
field_errors = self._claim_errors(normalized_output)
|
||||
field_errors = [
|
||||
*self._claim_errors(normalized_output),
|
||||
*self._relationship_claim_errors(normalized_output, targets),
|
||||
]
|
||||
if field_errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
|
||||
@@ -101,7 +334,7 @@ class RequirementsCommandHandler:
|
||||
claim_position += 1
|
||||
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
|
||||
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
|
||||
contract = {"schema_version": "cad.requirements-contract.v3.1", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
|
||||
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
|
||||
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
try:
|
||||
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
|
||||
@@ -176,12 +409,57 @@ class RequirementsCommandHandler:
|
||||
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
|
||||
"""Require explicit coverage for an unambiguous centered-bore target.
|
||||
|
||||
The compiler remains free to choose claims for open-ended CAD prose.
|
||||
A checklist item that explicitly says a bore is centered/concentric is
|
||||
different: dropping that relationship leaves a measurable user fact
|
||||
with no acceptance owner. The service only checks its presence here;
|
||||
the registry validates its numeric parameters independently.
|
||||
"""
|
||||
errors: list[dict[str, str]] = []
|
||||
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(
|
||||
claim.claim_kind == "concentric_bore_to_outer_cylinder"
|
||||
for claim in requirement.acceptance_claims
|
||||
):
|
||||
continue
|
||||
errors.append({
|
||||
"path": f"/requirements/{index}/acceptance_claims",
|
||||
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
|
||||
})
|
||||
return errors
|
||||
|
||||
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
|
||||
normalized = output.model_copy(deep=True)
|
||||
warnings: list[str] = []
|
||||
for target, requirement in zip(targets, normalized.requirements, strict=True):
|
||||
normalized_claims = []
|
||||
for claim in requirement.acceptance_claims:
|
||||
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
|
||||
warnings.append(
|
||||
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_unbacked_coaxial_bore_group(normalized, claim):
|
||||
warnings.append(
|
||||
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
|
||||
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
|
||||
warnings.append(
|
||||
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
|
||||
@@ -202,8 +480,104 @@ class RequirementsCommandHandler:
|
||||
"claim_kind": "visual",
|
||||
"expected": {"description": target[:360]},
|
||||
})]
|
||||
self._derive_centered_bore_claims(normalized, targets)
|
||||
return normalized, list(dict.fromkeys(warnings))
|
||||
|
||||
@staticmethod
|
||||
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
|
||||
"""Keep a circular-hole verifier from accepting or rejecting a slot.
|
||||
|
||||
``rectangular_corner_through_bore_pattern`` measures four complete
|
||||
cylindrical bores at equal edge offsets. An obround slot has two arc
|
||||
ends and straight flanks; treating its stated length as an edge offset
|
||||
produces an unsatisfiable contract even when the CAD is correct.
|
||||
"""
|
||||
return (
|
||||
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
|
||||
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
|
||||
"""Attach a measurable concentricity claim when its inputs are frozen.
|
||||
|
||||
The requirements compiler receives a Markdown checklist, not runtime
|
||||
geometry IDs. Once it has already compiled an external cylindrical
|
||||
diameter and an explicitly centred bore diameter, their relationship
|
||||
is a service-owned mechanical consequence. Requiring an author to
|
||||
remember the internal verifier name makes a complete user request
|
||||
fail for a bookkeeping omission rather than a CAD decision.
|
||||
"""
|
||||
outer_diameters: list[float] = []
|
||||
for requirement in output.requirements:
|
||||
for claim in requirement.acceptance_claims:
|
||||
expected = claim.expected
|
||||
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
|
||||
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
|
||||
outer_diameters.append(float(diameter))
|
||||
if not outer_diameters:
|
||||
return
|
||||
outer_diameter = max(outer_diameters)
|
||||
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
|
||||
for target, requirement in zip(targets, output.requirements, strict=True):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
|
||||
continue
|
||||
bore_diameter = next((
|
||||
float(claim.expected["diameter_mm"])
|
||||
for claim in requirement.acceptance_claims
|
||||
if claim.claim_kind in bore_claim_kinds
|
||||
and isinstance(claim.expected, dict)
|
||||
and isinstance(claim.expected.get("diameter_mm"), (int, float))
|
||||
and float(claim.expected["diameter_mm"]) > 0
|
||||
), None)
|
||||
if bore_diameter is None:
|
||||
continue
|
||||
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "concentric_bore_to_outer_cylinder",
|
||||
"expected": {
|
||||
"bore_diameter_mm": bore_diameter,
|
||||
"outer_diameter_mm": outer_diameter,
|
||||
"tolerance_mm": 0.01,
|
||||
},
|
||||
}))
|
||||
|
||||
@staticmethod
|
||||
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
|
||||
"""Reject a bore-group verifier when the contract has no such group.
|
||||
|
||||
``coaxial_through_bore_group`` measures multiple inner bores of one
|
||||
diameter. It cannot prove a lone central bore is concentric with an
|
||||
external cylindrical wall. This is a mechanical consistency check:
|
||||
some through-bore claim must request at least the group count.
|
||||
"""
|
||||
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
|
||||
return False
|
||||
expected = getattr(claim, "expected", {})
|
||||
if not isinstance(expected, dict):
|
||||
return True
|
||||
diameter = expected.get("diameter_mm")
|
||||
count = expected.get("count")
|
||||
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
|
||||
return True
|
||||
for requirement in output.requirements:
|
||||
for candidate in requirement.acceptance_claims:
|
||||
candidate_expected = getattr(candidate, "expected", {})
|
||||
if (
|
||||
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
|
||||
and isinstance(candidate_expected, dict)
|
||||
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
|
||||
and isinstance(candidate_expected.get("count"), int)
|
||||
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
|
||||
and int(candidate_expected["count"]) >= count
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
|
||||
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""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``.
|
||||
It selects only the structured schema visible from persisted state. Feature
|
||||
selection is server-owned: the scheduler chooses one atomic DAG node and the
|
||||
author can submit only that node's fragment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,6 +29,7 @@ from app.cad_agent.application.llm_contracts import (
|
||||
)
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash
|
||||
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, retry_resume_event, transition
|
||||
@@ -36,6 +37,7 @@ from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, M
|
||||
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
_FEATURE_REPLAN_FAILURE_LIMIT = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -248,6 +250,13 @@ class WorkflowCoordinator:
|
||||
if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY}:
|
||||
yield "task_terminal", self._projected_terminal(task_id, state)
|
||||
return
|
||||
if state.phase == TaskPhase.FEATURE_BUILDING:
|
||||
recovered = self.actions.recover_feature_build(task_id)
|
||||
yield "feature_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.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}
|
||||
@@ -434,6 +443,130 @@ class WorkflowCoordinator:
|
||||
yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
feedback = []
|
||||
continue
|
||||
if state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
exhausted = self._feature_replan_exhausted(task_id, state)
|
||||
if exhausted is not None:
|
||||
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "feature_plan_no_progress_limit",
|
||||
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
||||
"message": "The same atomic feature exhausted its cross-plan replan budget.",
|
||||
"checkpoint_preserved": bool(state.active_revision),
|
||||
"revision_id": state.active_revision,
|
||||
**exhausted,
|
||||
}])
|
||||
yield "task_terminal", {
|
||||
"taskId": task_id,
|
||||
"lifecycle": "failed",
|
||||
"revisionId": state.active_revision,
|
||||
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
||||
"message": "The same atomic feature repeatedly failed after local replanning; the last executable checkpoint remains available.",
|
||||
}
|
||||
return
|
||||
plan_schema = self.requirements.feature_plan_schema(task_id)
|
||||
tools = [self._tool("write_feature_plan", plan_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, "write_feature_plan", result, format_errors, feedback)
|
||||
yield "feature_plan", {"taskId": task_id, "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
|
||||
validation = canonical_validate(raw, FeaturePlan)
|
||||
dynamic_error = canonical_validate_schema(raw, plan_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 "feature_plan", self._event(task_id, name, validation.payload(), "error", usage)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
command = self.requirements.submit_feature_plan(task_id, validation, invocation_id=self._invocation_id(task_id))
|
||||
if isinstance(command, Rejected):
|
||||
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="write_feature_plan")
|
||||
yield "feature_plan", self._event(task_id, name, command.error.payload(), "error", usage)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
yield "feature_plan_ready", self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
feedback = []
|
||||
continue
|
||||
if state.phase == TaskPhase.SCHEDULING_FEATURE:
|
||||
command = self.actions.schedule_next_feature(task_id)
|
||||
if isinstance(command, Rejected):
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": command.error.code.value, "message": command.error.message}
|
||||
return
|
||||
yield "feature_scheduled", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
||||
continue
|
||||
if state.phase == TaskPhase.FEATURE_PENDING:
|
||||
observed = action_observations.setdefault(state.working_head, set())
|
||||
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 "feature_result", {"taskId": task_id, "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 == "inspect_topology":
|
||||
validation = canonical_validate(raw, StatelessTopologyRequest)
|
||||
if isinstance(validation, WorkflowError):
|
||||
yield "feature_result", self._event(task_id, name, validation.payload(), "error", usage)
|
||||
continue
|
||||
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 = [{"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}]
|
||||
continue
|
||||
fragment = canonical_json_object(raw)
|
||||
if isinstance(fragment, WorkflowError):
|
||||
yield "feature_result", self._event(task_id, name, fragment.payload(), "error", usage)
|
||||
feedback = [self._feedback(fragment)]
|
||||
continue
|
||||
command = self.actions.submit_feature_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id))
|
||||
if isinstance(command, Rejected):
|
||||
after = self.repository.get_state(task_id)
|
||||
yield "feature_result", self._event(task_id, name, command.error.payload(), "error", usage)
|
||||
if after is not None and after.version != state.version:
|
||||
feedback = [self._feedback(command.error)]
|
||||
continue
|
||||
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
yield "feature_result", self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
feedback = []
|
||||
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")]
|
||||
@@ -688,6 +821,14 @@ class WorkflowCoordinator:
|
||||
yield "candidate_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
||||
continue
|
||||
if state.phase == TaskPhase.FINAL_VALIDATION:
|
||||
facts = self.actions._facts(task_id, state.active_revision)
|
||||
if not (facts.get("report") or {}).get("render_manifest"):
|
||||
try:
|
||||
self.runtime.render_review_bundle(str(self.artifacts.artifact_path(task_id, f"revisions/{state.active_revision}")))
|
||||
except Exception as error:
|
||||
yield self._service_failure(task_id, state, WorkflowError(ErrorCode.RENDER_SERVICE_UNAVAILABLE, str(error)[:1000], retryable=True))
|
||||
return
|
||||
yield "final_render_ready", {"taskId": task_id, "revisionId": state.active_revision, "status": "success"}
|
||||
recovered = self.actions.recover_final_review(task_id)
|
||||
if recovered is not None:
|
||||
if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed":
|
||||
@@ -768,6 +909,15 @@ class WorkflowCoordinator:
|
||||
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
||||
continue
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is not None and state.feature_plan_hash:
|
||||
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "feature_plan_no_progress_limit", "code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
||||
"message": "The feature DAG reached its bounded turn limit.",
|
||||
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
|
||||
}])
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.NO_PROGRESS_LIMIT.value, "message": "The feature DAG reached its bounded turn limit; the last executable checkpoint remains available."}
|
||||
return
|
||||
terminal = self._best_effort_terminal(task_id, state, ErrorCode.NO_PROGRESS_LIMIT, "The workflow reached its bounded turn limit.") if state else None
|
||||
if terminal:
|
||||
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
|
||||
@@ -781,6 +931,14 @@ class WorkflowCoordinator:
|
||||
yield self._storage_failure(task_id, str(error))
|
||||
except Exception as error:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is not None and state.feature_plan_hash 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": "feature_plan_internal_failure", "message": str(error)[:1000],
|
||||
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
|
||||
}])
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]}
|
||||
return
|
||||
terminal = self._best_effort_terminal(task_id, state, ErrorCode.FAILED_INTERNAL, str(error)[:1000]) if state else None
|
||||
if terminal:
|
||||
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
|
||||
@@ -814,6 +972,19 @@ class WorkflowCoordinator:
|
||||
return None
|
||||
details = budget.payload()
|
||||
details["next_actor"] = actor
|
||||
if state.feature_plan_hash:
|
||||
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 feature DAG converged.",
|
||||
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision, **details,
|
||||
}])
|
||||
return "task_terminal", {
|
||||
"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision,
|
||||
"code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
|
||||
"message": "Configured model-call budget is exhausted before the feature DAG converged; the last executable checkpoint remains available.",
|
||||
"budget": details, "blockerType": "call_budget_exhausted", "userActionRequired": False,
|
||||
}
|
||||
terminal = self._best_effort_terminal(
|
||||
task_id,
|
||||
state,
|
||||
@@ -1235,7 +1406,7 @@ class WorkflowCoordinator:
|
||||
return []
|
||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
"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),
|
||||
@@ -1246,31 +1417,85 @@ class WorkflowCoordinator:
|
||||
}
|
||||
elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET:
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
|
||||
"instruction": "Write # Completion Target with unique - [ ] checklist items. Each item must describe one independently observable final feature or condition. Do not add requirements not present in the frozen requirements document and do not include runtime identifiers.",
|
||||
}
|
||||
elif state.phase == TaskPhase.COMPILING_REQUIREMENTS:
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
"source_requirements": self.artifacts.read_source_requirements(task_id),
|
||||
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
|
||||
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
|
||||
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
|
||||
"verifier_registry": self.requirements.registry.expected_one_of_schema(),
|
||||
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance.",
|
||||
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance. An obround, slotted, or long-slot feature is not a rectangular_corner_through_bore_pattern: keep it visual unless a dedicated slot verifier is available. Every explicit centered, concentric, or coaxial bore must have concentric_bore_to_outer_cylinder coverage. coaxial_through_bore_group is only for two or more same-diameter inner bores. For one central bore concentric with an external cylinder, use concentric_bore_to_outer_cylinder with bore_diameter_mm and outer_diameter_mm.",
|
||||
}
|
||||
elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN:
|
||||
elif state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
active_plan = self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None
|
||||
contract = self._requirements_contract(task_id, state) or {}
|
||||
deterministic_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") == "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
visual_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
required_replacements: list[str] = []
|
||||
parent_plan_hash = ""
|
||||
if isinstance(active_plan, dict):
|
||||
try:
|
||||
parsed_plan = FeaturePlan.model_validate(active_plan)
|
||||
parent_plan_hash = plan_hash(parsed_plan)
|
||||
required_replacements = sorted(self.requirements._required_replacements(parsed_plan, task_id))
|
||||
except ValueError:
|
||||
pass
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
|
||||
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
|
||||
"compiled_contract": self._requirements_contract(task_id, state),
|
||||
"instruction": "Write # Modeling Plan with a short ordered list of feature-construction steps. It is a frozen execution guide only: do not add, remove, or reinterpret requirements and do not include runtime identifiers.",
|
||||
"compiled_contract": contract,
|
||||
"claim_ownership_binding": {
|
||||
"node_claim_ids_must_be_drawn_only_from": deterministic_claim_ids,
|
||||
"final_claim_ids_must_equal": visual_claim_ids,
|
||||
"visual_only_nodes_must_use_empty_claim_ids": True,
|
||||
},
|
||||
"supported_atomic_ids": list(self.runtime.supported_atomic_ids()),
|
||||
"active_feature_plan": active_plan,
|
||||
"plan_lineage_binding": {
|
||||
"parent_plan_hash": parent_plan_hash,
|
||||
"replaces_node_ids": required_replacements,
|
||||
},
|
||||
"feature_node_statuses": self._feature_node_statuses(task_id, active_plan),
|
||||
"replanning_evidence": self._replanning_evidence(task_id, state),
|
||||
"instruction": (
|
||||
"Write the complete feature-plan JSON. Each node is exactly one runtime atomic feature. "
|
||||
"Every deterministic frozen claim must belong to exactly one node; every visual claim must be in final_claim_ids. "
|
||||
"Never place a visual claim in any node claim_ids. Nodes for ribs, chamfers, slots, or other visual-only work are valid with claim_ids: []. "
|
||||
"Use dependency edges only for direct geometric prerequisites and unique fixed priorities. "
|
||||
"The tool schema binds parent_plan_hash and replaces_node_ids to the service values: copy those exact values, never use the requirements-contract hash. "
|
||||
"For a revision, preserve completed nodes byte-for-byte and use new IDs for replacements."
|
||||
),
|
||||
}
|
||||
else:
|
||||
contract = self._requirements_contract(task_id, state) or {}
|
||||
action = state.pending_feature
|
||||
feature_node_turn = state.phase == TaskPhase.FEATURE_PENDING and action is not None
|
||||
compact = [{
|
||||
"requirement_id": item.get("requirement_id"),
|
||||
"statement": item.get("statement"),
|
||||
"acceptance_claims": [
|
||||
{
|
||||
@@ -1281,14 +1506,20 @@ class WorkflowCoordinator:
|
||||
if isinstance(claim, dict)
|
||||
],
|
||||
} for item in contract.get("requirements") or () if isinstance(item, dict)]
|
||||
action = state.pending_action
|
||||
if feature_node_turn:
|
||||
owned_requirements = set(action.requirement_ids)
|
||||
compact = [
|
||||
item for item in compact
|
||||
if str(item.get("requirement_id") or "") in owned_requirements
|
||||
]
|
||||
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."
|
||||
instruction = "The service has scheduled one atomic feature from the immutable DAG. Submit exactly that feature's CDSL fragment."
|
||||
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 = []
|
||||
sketch_workplane_candidates = []
|
||||
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))
|
||||
@@ -1297,7 +1528,35 @@ 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.1", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), "modeling_plan_markdown": self._read_markdown(task_id, state.modeling_plan_path), "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}
|
||||
if (
|
||||
feature_node_turn
|
||||
and state.active_revision
|
||||
and str((operation or {}).get("fragment_shape", {}).get("sketch") or "forbidden") == "required"
|
||||
):
|
||||
sketch_workplane_candidates = self._sketch_workplane_candidates(
|
||||
self.artifacts.read_topology(task_id, state.active_revision)
|
||||
)
|
||||
if sketch_workplane_candidates:
|
||||
instruction += (
|
||||
" sketch_workplane_candidates are measured planar material faces. "
|
||||
"For a blind cut, select a plane whose material region covers the intended profile; "
|
||||
"do not choose a newer or higher face merely because it is the last feature."
|
||||
)
|
||||
current_claims = self.actions.claim_summary(task_id, state)
|
||||
if feature_node_turn:
|
||||
current_claims = [
|
||||
item for item in current_claims
|
||||
if str(item.get("claim_id") or "") in set(action.claim_ids)
|
||||
]
|
||||
content = {"protocol": "cad.v3.2.feature-dag", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in current_claims], "model_summary": self.actions.model_summary(task_id, state), "pending_feature": self._public_pending_context(state), "direct_upstream_facts": self._direct_upstream_facts(task_id, 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)), "sketch_workplane_candidates": sketch_workplane_candidates, "recent_failures": self._recent_failure_constraints(task_id, state), "instruction": instruction}
|
||||
if not feature_node_turn:
|
||||
content.update({
|
||||
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
|
||||
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
|
||||
"feature_plan": self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None,
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"feature_node_statuses": self._feature_node_statuses(task_id, None),
|
||||
})
|
||||
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:]]
|
||||
|
||||
@@ -1322,6 +1581,160 @@ class WorkflowCoordinator:
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
def _feature_node_statuses(self, task_id: str, plan_payload: dict[str, Any] | None) -> dict[str, str]:
|
||||
try:
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
|
||||
state = self.repository.get_state(task_id)
|
||||
plan = FeaturePlan.model_validate(plan_payload) if isinstance(plan_payload, dict) else FeaturePlan.model_validate(self.artifacts.read_json(task_id, state.feature_plan_path) if state and state.feature_plan_path else None)
|
||||
return FeatureScheduler(plan, self.repository.ledger_events(task_id)).statuses()
|
||||
except (ValueError, TypeError, OSError):
|
||||
return {}
|
||||
|
||||
def _direct_upstream_facts(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
"""Project only verified direct dependencies for a scheduled node.
|
||||
|
||||
The current model summary describes the working head. This additional
|
||||
projection lets the author distinguish the exact prerequisite nodes
|
||||
without exposing arbitrary historical topology or old failed stages.
|
||||
"""
|
||||
pending = state.pending_feature
|
||||
if pending is None or not pending.depends_on_node_ids:
|
||||
return []
|
||||
verified: dict[str, dict[str, Any]] = {}
|
||||
for event in self.repository.ledger_events(task_id):
|
||||
if event.get("event") == "feature_node_verified":
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if node_id in pending.depends_on_node_ids:
|
||||
verified[node_id] = event
|
||||
result: list[dict[str, Any]] = []
|
||||
for node_id in pending.depends_on_node_ids:
|
||||
event = verified.get(node_id)
|
||||
if event is None:
|
||||
continue
|
||||
revision_id = str(event.get("revision_id") or "")
|
||||
verification = self.artifacts.read_json(task_id, f"revisions/{revision_id}/node-verification.json") if revision_id else None
|
||||
evidence = verification if isinstance(verification, dict) else {}
|
||||
result.append({
|
||||
"node_id": node_id,
|
||||
"revision_id": revision_id,
|
||||
"feature_id": str(event.get("feature_id") or evidence.get("feature_id") or ""),
|
||||
"claim_results": [
|
||||
{
|
||||
"claim_id": str(item.get("claim_id") or ""),
|
||||
"status": str(item.get("status") or ""),
|
||||
"claim_kind": str(item.get("claim_kind") or ""),
|
||||
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
|
||||
}
|
||||
for item in evidence.get("claim_results") or ()
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
"operation_verifier_results": [
|
||||
{
|
||||
"claim_kind": str(item.get("claim_kind") or ""),
|
||||
"status": str(item.get("status") or ""),
|
||||
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
|
||||
}
|
||||
for item in evidence.get("operation_verifier_results") or ()
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
"health": evidence.get("health") if isinstance(evidence.get("health"), dict) else {},
|
||||
})
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _sketch_workplane_candidates(topology: dict[str, Any] | None, *, limit: int = 12) -> list[dict[str, Any]]:
|
||||
"""Expose compact, measured planes for sketch operations without selectors.
|
||||
|
||||
Sketch extrusions deliberately do not use B-rep selector tokens. The
|
||||
author still needs enough geometry to choose a material face instead of
|
||||
placing a profile on the most recently created boss.
|
||||
"""
|
||||
records = topology.get("records") if isinstance(topology, dict) else None
|
||||
if not isinstance(records, list):
|
||||
return []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
seen: set[tuple[float, ...]] = set()
|
||||
for record in records:
|
||||
geometry = record.get("geometry") if isinstance(record, dict) else None
|
||||
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
|
||||
normal = geometry.get("normal") if isinstance(geometry, dict) else None
|
||||
bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None
|
||||
if (
|
||||
not isinstance(geometry, dict)
|
||||
or geometry.get("surface_type") != "plane"
|
||||
or not isinstance(center, list)
|
||||
or not isinstance(normal, list)
|
||||
or len(center) != 3
|
||||
or len(normal) != 3
|
||||
or not isinstance(bbox, list)
|
||||
or len(bbox) != 6
|
||||
):
|
||||
continue
|
||||
try:
|
||||
point = [round(float(value), 4) for value in center]
|
||||
direction = [round(float(value), 4) for value in normal]
|
||||
bounds = [round(float(value), 4) for value in bbox]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
key = tuple(direction + point + bounds)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
footprint = abs((bounds[3] - bounds[0]) * (bounds[4] - bounds[1]))
|
||||
candidates.append({
|
||||
"point_mm": point,
|
||||
"normal": direction,
|
||||
"bbox_mm": bounds,
|
||||
"footprint_bbox_area_mm2": round(footprint, 4),
|
||||
})
|
||||
# Favor outward horizontal faces, then the broadest support surface.
|
||||
# A base top commonly supports an outer slot while a taller boss does
|
||||
# not; ordering by Z would teach the opposite choice.
|
||||
candidates.sort(key=lambda item: (
|
||||
-float(item["normal"][2]),
|
||||
-float(item["footprint_bbox_area_mm2"]),
|
||||
-float(item["point_mm"][2]),
|
||||
item["bbox_mm"],
|
||||
))
|
||||
return candidates[:limit]
|
||||
|
||||
def _replanning_evidence(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
"""Provide bounded server evidence that led to this local replan."""
|
||||
evidence: list[dict[str, Any]] = []
|
||||
for event in reversed(self.repository.ledger_events(task_id)):
|
||||
if event.get("plan_hash") != state.feature_plan_hash:
|
||||
continue
|
||||
if event.get("event") == "feature_node_failed":
|
||||
evidence.append({
|
||||
"kind": "node_failure",
|
||||
"node_id": str(event.get("node_id") or ""),
|
||||
"failure_class": str(event.get("failure_class") or ""),
|
||||
"attempt": int(event.get("attempt") or 0),
|
||||
"message": str(event.get("message") or "")[:500],
|
||||
"blockers": (event.get("blockers") or [])[:8] if isinstance(event.get("blockers"), list) else [],
|
||||
})
|
||||
elif event.get("event") == "final_visual_reviewed" and event.get("visual_not_passed"):
|
||||
evidence.append({
|
||||
"kind": "final_visual_review",
|
||||
"claim_ids": [str(value) for value in event.get("visual_not_passed") or () if str(value)],
|
||||
"issues": [str(value)[:500] for value in event.get("issues") or () if str(value)],
|
||||
"evidence": [str(value)[:500] for value in event.get("evidence") or () if str(value)],
|
||||
})
|
||||
elif event.get("event") == "feature_plan_completion_failed":
|
||||
evidence.append({
|
||||
"kind": "final_deterministic_validation",
|
||||
"claim_ids": [str(value) for value in event.get("failed_claim_ids") or () if str(value)],
|
||||
"message": str(event.get("message") or "")[:500],
|
||||
"claim_results": [
|
||||
self._public_claim_result(item)
|
||||
for item in event.get("claim_results") or ()
|
||||
if isinstance(item, dict) and item.get("deterministic") and item.get("status") != "pass"
|
||||
],
|
||||
})
|
||||
if len(evidence) == 8:
|
||||
break
|
||||
return evidence
|
||||
|
||||
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
||||
"""Expose the persisted requirement question when a task is parked.
|
||||
|
||||
@@ -1419,7 +1832,15 @@ class WorkflowCoordinator:
|
||||
@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
|
||||
return {
|
||||
"node_id": action.node_id,
|
||||
"plan_hash": action.plan_hash,
|
||||
"intent": action.intent,
|
||||
"operation": action.atomic_id,
|
||||
"expected_change": action.expected_change,
|
||||
"claim_ids": list(action.claim_ids),
|
||||
"depends_on_node_ids": list(action.depends_on_node_ids),
|
||||
} if action else None
|
||||
|
||||
@staticmethod
|
||||
def _coordinate_protocol(state: TaskState) -> dict[str, str]:
|
||||
@@ -1471,13 +1892,20 @@ class WorkflowCoordinator:
|
||||
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:
|
||||
is_current_feature_failure = (
|
||||
event.get("event") == "feature_node_failed"
|
||||
and event.get("plan_hash") == state.feature_plan_hash
|
||||
and state.pending_feature is not None
|
||||
and event.get("node_id") == state.pending_feature.node_id
|
||||
)
|
||||
if not is_current_feature_failure and event.get("checkpoint_revision") != state.active_revision:
|
||||
continue
|
||||
if not event.get("normalized_error_code"):
|
||||
normalized_error_code = str(event.get("normalized_error_code") or event.get("code") or "")
|
||||
if not normalized_error_code:
|
||||
continue
|
||||
constraints.append({
|
||||
"atomic_id": str(event.get("atomic_id") or ""),
|
||||
"normalized_error_code": str(event.get("normalized_error_code") or ""),
|
||||
"normalized_error_code": normalized_error_code,
|
||||
"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),
|
||||
@@ -1487,6 +1915,42 @@ class WorkflowCoordinator:
|
||||
break
|
||||
return constraints
|
||||
|
||||
def _feature_replan_exhausted(self, task_id: str, state: TaskState) -> dict[str, Any] | None:
|
||||
"""Bound repeated replacement plans at one immutable checkpoint.
|
||||
|
||||
Node IDs must change on every subgraph revision, so a node-local retry
|
||||
counter alone cannot stop a planner from replacing the same failed
|
||||
atomic operation forever. A successful feature creates a new revision;
|
||||
therefore checkpoint + atomic operation is a stable, narrow boundary
|
||||
for this cross-plan budget.
|
||||
"""
|
||||
if state.phase != TaskPhase.REPLANNING_FEATURE_SUBGRAPH:
|
||||
return None
|
||||
terminal = [
|
||||
event for event in self.repository.ledger_events(task_id)
|
||||
if event.get("event") == "feature_node_failed"
|
||||
and bool(event.get("terminal"))
|
||||
and str(event.get("checkpoint_revision") or "") == state.active_revision
|
||||
]
|
||||
if not terminal:
|
||||
return None
|
||||
atomic_id = str(terminal[-1].get("atomic_id") or "")
|
||||
if not atomic_id:
|
||||
return None
|
||||
matching = [
|
||||
event for event in terminal
|
||||
if str(event.get("atomic_id") or "") == atomic_id
|
||||
]
|
||||
if len(matching) < _FEATURE_REPLAN_FAILURE_LIMIT:
|
||||
return None
|
||||
return {
|
||||
"atomic_id": atomic_id,
|
||||
"checkpoint_revision": state.active_revision,
|
||||
"terminal_failure_count": len(matching),
|
||||
"limit": _FEATURE_REPLAN_FAILURE_LIMIT,
|
||||
"node_ids": [str(event.get("node_id") or "") for event in matching],
|
||||
}
|
||||
|
||||
@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."""
|
||||
@@ -1697,6 +2161,18 @@ class WorkflowCoordinator:
|
||||
feedback[:] = [self._feedback(error)]
|
||||
if counters[name] < self.config.format_error_limit:
|
||||
return None
|
||||
if state.feature_plan_hash:
|
||||
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),
|
||||
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
|
||||
}])
|
||||
return "task_terminal", {
|
||||
"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision,
|
||||
"code": ErrorCode.FAILED_AUTHOR_FORMAT.value,
|
||||
"message": f"{actor.capitalize()} repeatedly failed the canonical schema; the last executable checkpoint remains available.",
|
||||
"tool": name, "field_errors": list(error.field_errors),
|
||||
}
|
||||
terminal = self._best_effort_terminal(
|
||||
task_id,
|
||||
state,
|
||||
@@ -1760,6 +2236,14 @@ class WorkflowCoordinator:
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
|
||||
return self._service_failure(task_id, state, error)
|
||||
if tool == "write_feature_plan":
|
||||
# A plan revision is authored against an already-frozen contract.
|
||||
# Its field errors must use the plan tool's own retry budget, not
|
||||
# the requirements compiler's shared counter. Otherwise one
|
||||
# earlier requirements correction can make the first replan
|
||||
# attempt terminal, even though the state remains perfectly
|
||||
# recoverable in REPLANNING_FEATURE_SUBGRAPH.
|
||||
return self._format_failure(task_id, state, tool, error, counters, feedback)
|
||||
return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool)
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user