添加cadfs_to_cdsl代码,优化cad生成
This commit is contained in:
@@ -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