添加cadfs_to_cdsl代码,优化cad生成

This commit is contained in:
2026-09-03 10:40:51 +08:00
parent f3eb5dff54
commit c25b73c71e
34 changed files with 3586 additions and 147 deletions
+502 -18
View File
@@ -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