wip: preserve local engine and agent workflow changes before ganjihong refactor integration

This commit is contained in:
2026-09-09 17:08:24 +08:00
parent a5c3ec1f36
commit 387b99e255
112 changed files with 3543 additions and 12177 deletions
@@ -1 +1 @@
"""Application command handlers and context assembly for protocol v3."""
"""Application workflow and compiler for the Authoring CDSL protocol."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
"""Compile model-facing Authoring CDSL into server-owned runtime CDSL."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any, Callable
from jsonschema import Draft202012Validator
from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code
class AuthoringCompileError(ValueError):
def __init__(self, code: str, message: str, *, path: str = "") -> None:
self.code, self.path = code, path
super().__init__(message)
class AuthoringCompiler:
def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None:
self.operation_contract = operation_contract
self.version = version
def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
try:
validate_finite(raw)
doc = AuthoringDocument.model_validate(raw)
except Exception as error:
raise AuthoringCompileError(validation_error_code(error), str(error)) from error
source_features = [feature for body in doc.bodies for feature in body.features]
dependencies, implicit_selector_dependencies = self._effective_dependencies(doc)
ordered = self._topological(doc, dependencies)
# Identity follows the source document, while execution follows the
# dependency graph. A dependency reordering must never renumber IDs.
feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)}
body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)}
features: list[dict[str, Any]] = []
sketches: list[dict[str, Any]] = []
sketch_index = 0
source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)}
sketch_ids_by_feature = {
feature.name: f"sketch_{source_positions[feature.name]:03d}"
for feature in source_features
if feature.sketch is not None
}
for feature in ordered:
try:
contract = self.operation_contract(feature.operation)
except Exception as error:
raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error
params = self._references(
deepcopy(feature.params), contract, feature_ids, feature.name,
sketch_ids_by_feature=sketch_ids_by_feature,
)
self._validate_params(contract, params, feature.name)
selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors]
selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}
fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"}
required_selectors = fragment_shape["selector_tokens"] == "required"
runtime_feature_selectors: list[dict[str, Any]] = []
if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]:
raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors")
if not required_selectors and selectors:
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors")
if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors):
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors")
if required_selectors:
slot = str(selector_policy["slot"])
if slot == "feature.selectors":
runtime_feature_selectors = selectors
elif slot.startswith("params."):
parameter = slot.removeprefix("params.")
if "." in parameter:
raise AuthoringCompileError(
"OPERATION_UNSUPPORTED",
f"runtime has no safe selector binding for {slot}",
path=f"features.{feature.name}.selectors",
)
if parameter in params:
raise AuthoringCompileError(
"AUTHOR_FORBIDDEN_FIELD",
f"{parameter} is server-injected from selectors",
path=f"features.{feature.name}.params.{parameter}",
)
params[parameter] = selectors[0] if len(selectors) == 1 else selectors
else:
raise AuthoringCompileError(
"OPERATION_UNSUPPORTED",
f"runtime has no safe selector binding for {slot}",
path=f"features.{feature.name}.selectors",
)
output = {
"id": feature_ids[feature.name], "atomic_id": feature.operation,
"depends_on": [feature_ids[name] for name in dependencies[feature.name]],
"params": params,
**({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}),
}
needs_sketch = fragment_shape["sketch"] == "required"
if needs_sketch and feature.sketch is None:
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch")
if not needs_sketch and feature.sketch is not None:
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch")
if feature.sketch is not None:
sketch_index += 1
sketch_id = f"sketch_{source_positions[feature.name]:03d}"
sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))})
output["sketch_id"] = sketch_id
features.append(output)
runtime = {
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
"part_id": "compiled", "meta": {"unit": "mm"},
"bodies": [
{"id": body_ids[body.name], "name": body.name}
for body in doc.bodies
],
"geometry": {"sketches": sketches}, "features": features,
}
digest = sha256(self._canonical_json(raw)).hexdigest()
return runtime, {
"schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version,
"source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids,
"sketch_ids": [item["id"] for item in sketches],
"implicit_selector_dependencies": implicit_selector_dependencies,
}
@staticmethod
def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""Make every declared selector source an auditable graph dependency.
A selector is already an explicit local source reference. Requiring the
author to repeat that same edge in a second field only creates a
formatting failure; it does not add CAD intent. The compiler therefore
adds the direct source edge deterministically and records it in the
audit. It never selects a substitute topology element.
"""
by_name = {item.name: item for body in doc.bodies for item in body.features}
dependencies: dict[str, list[str]] = {}
implicit: dict[str, list[str]] = {}
for feature in by_name.values():
values = list(feature.depends_on)
additions: list[str] = []
for selector in feature.selectors:
source = selector.source.split(".", 1)[0]
if source not in by_name:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID",
f"unknown selector source: {selector.source}",
path=f"features.{feature.name}.selectors",
)
if source not in values:
values.append(source)
additions.append(source)
dependencies[feature.name] = values
if additions:
implicit[feature.name] = additions
return dependencies, implicit
@staticmethod
def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]:
by_name = {item.name: item for body in doc.bodies for item in body.features}
result: list[Any] = []
visiting: set[str] = set()
done: set[str] = set()
def visit(name: str) -> None:
if name in visiting:
raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}")
if name in done:
return
visiting.add(name)
for dependency in dependencies[name]:
visit(dependency)
visiting.remove(name); done.add(name); result.append(by_name[name])
for body in doc.bodies:
for feature in body.features:
visit(feature.name)
return result
@staticmethod
def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]:
source = selector.source.split(".", 1)[0]
if source not in feature_ids:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}")
source_feature = next(item for item in source_features if item.name == source)
role = selector.source.split(".", 1)[1]
role = AuthoringCompiler._runtime_role(role, source_feature.operation)
return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match}
@staticmethod
def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]:
"""Lower the small Authoring sketch language to the generic runtime form."""
profile = sketch["profile"]
if profile["type"] == "circle":
profile = {
"type": "circle",
"center": profile["center_mm"],
"radius_mm": float(profile["diameter_mm"]) / 2.0,
}
return {"workplane": sketch["workplane"], "profile": profile}
@staticmethod
def _runtime_role(role: str, operation: str) -> str:
if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}:
return role
family = (
"extrude" if operation.startswith("extrude") else
"sweep" if operation.startswith("sweep") else
"loft" if operation.startswith("loft") else
"cylinder" if operation == "cylinder_add" else ""
)
if role in {"top_planar_face", "end_face"} and family:
return family + ".end"
if role in {"bottom_planar_face", "start_face"} and family:
return family + ".start"
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}")
@staticmethod
def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None:
schema = contract.get("author_params_schema") or {"type": "object"}
errors = list(Draft202012Validator(schema).iter_errors(params))
if errors:
first = errors[0]
path = ".".join(str(item) for item in first.absolute_path)
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip("."))
@staticmethod
def _references(
params: dict[str, Any],
contract: dict[str, Any],
feature_ids: dict[str, str],
feature_name: str,
*,
sketch_ids_by_feature: dict[str, str],
) -> dict[str, Any]:
params = AuthoringCompiler._rewrite_named_references(
params, feature_ids, sketch_ids_by_feature, feature_name,
)
policy = contract.get("reference_policy") or {"mode": "none"}
if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors":
return params
key = str(policy["slot"]).removeprefix("params.")
value = params.get(key)
if isinstance(value, list):
names = value
elif value is None and policy["min_items"] == 0:
names = []
elif isinstance(value, str):
names = [value]
else:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}")
runtime_ids = set(feature_ids.values())
if not all(isinstance(item, str) and item in runtime_ids for item in names):
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}")
if not policy["min_items"] <= len(names) <= policy["max_items"]:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}")
return params
@staticmethod
def _rewrite_named_references(
value: Any,
feature_ids: dict[str, str],
sketch_ids_by_feature: dict[str, str],
consuming_feature: str,
key: str = "",
) -> Any:
if isinstance(value, dict):
return {
item_key: AuthoringCompiler._rewrite_named_references(
item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key,
)
for item_key, item_value in value.items()
}
if isinstance(value, list):
return [
AuthoringCompiler._rewrite_named_references(
item, feature_ids, sketch_ids_by_feature, consuming_feature, key,
)
for item in value
]
if not isinstance(value, str):
return value
if key == "profile_sketch_ids":
if value not in sketch_ids_by_feature:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}",
path=f"features.{consuming_feature}.params.{key}",
)
return sketch_ids_by_feature[value]
if key.endswith("_feature_id") or key.endswith("_feature_ids"):
if value not in feature_ids:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}",
path=f"features.{consuming_feature}.params.{key}",
)
return feature_ids[value]
return value
@staticmethod
def _canonical_json(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
@@ -0,0 +1,169 @@
"""Strict model-facing Authoring CDSL contract.
This is intentionally separate from the runtime CDSL: model output contains
only document-local names and declarative references. Runtime identities are
allocated by :mod:`authoring_compiler`.
"""
from __future__ import annotations
import math
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_NAME = r"^[a-z][a-z0-9_]{0,63}$"
_FORBIDDEN = {
"id",
"task_id", "revision_id", "candidate_id", "action_id", "requirement_id",
"claim_id", "evidence_id", "feature_id", "sketch_id", "body_id",
"stable_id", "snapshot_id", "owner_feature_id", "working_head",
"selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken",
"host_face", "mirror_plane",
}
def validation_error_code(error: Exception) -> str:
"""Map strict model validation failures to a stable public diagnostic."""
return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID"
class AuthorModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
@model_validator(mode="before")
@classmethod
def reject_internal_fields(cls, value: Any) -> Any:
if isinstance(value, dict):
found = sorted(
key for key in value
if isinstance(key, str)
and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN)
)
if found:
raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}")
for nested in value.values():
cls.reject_internal_fields(nested)
return value
if isinstance(value, list):
for item in value:
cls.reject_internal_fields(item)
return value
class AuthorWorkplane(AuthorModel):
"""A fully explicit local sketch frame in world millimetres."""
origin_mm: list[float] = Field(min_length=3, max_length=3)
x_dir: list[float] = Field(min_length=3, max_length=3)
normal: list[float] = Field(min_length=3, max_length=3)
class AuthorCircleProfile(AuthorModel):
"""A declarative circle expressed with the user-facing diameter."""
type: Literal["circle"]
diameter_mm: float = Field(gt=0)
center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2)
class AuthorPolygonProfile(AuthorModel):
"""A closed polygon in the local sketch workplane."""
type: Literal["polygon"]
vertices: list[list[float]] = Field(min_length=3)
@field_validator("vertices")
@classmethod
def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]:
if any(len(point) != 2 for point in value):
raise ValueError("polygon vertices must have exactly two coordinates")
return value
class AuthorSketch(AuthorModel):
"""The only authoring sketch form currently accepted by the compiler."""
workplane: AuthorWorkplane
profile: AuthorCircleProfile | AuthorPolygonProfile
class SelectorIntent(AuthorModel):
"""A local feature-output reference, never a Runtime selector token."""
kind: Literal["face", "edge", "axis", "plane", "vertex", "body"]
source: str = Field(
min_length=3,
max_length=160,
description="A local feature output in the form <feature_name>.<output_role>.",
)
match: Literal["unique", "all"] = "unique"
@field_validator("source")
@classmethod
def require_feature_output_reference(cls, value: str) -> str:
feature, separator, role = value.partition(".")
if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role):
raise ValueError("selector source must be <feature_name>.<output_role>")
return value
class AuthorFeature(AuthorModel):
name: str = Field(pattern=_NAME)
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
params: dict[str, Any] = Field(
default_factory=dict,
description="Only parameters from this feature operation's supplied params_schema.",
)
depends_on: list[str] = Field(default_factory=list, max_length=32)
selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32)
sketch: AuthorSketch | None = Field(
default=None,
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
)
class AuthorBody(AuthorModel):
name: str = Field(pattern=_NAME)
features: list[AuthorFeature] = Field(min_length=1, max_length=256)
class AuthoringDocument(AuthorModel):
schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$")
units: str = Field(default="mm", pattern=r"^mm$")
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
assumptions: list[str] = Field(default_factory=list, max_length=64)
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
@model_validator(mode="after")
def validate_symbols(self) -> "AuthoringDocument":
validate_finite(self.model_dump(mode="python"))
bodies = [b.name for b in self.bodies]
if len(bodies) != len(set(bodies)):
raise ValueError("duplicate body name")
names: set[str] = set()
for body in self.bodies:
for feature in body.features:
if feature.name in names:
raise ValueError(f"duplicate feature name: {feature.name}")
names.add(feature.name)
for body in self.bodies:
for feature in body.features:
if len(feature.depends_on) != len(set(feature.depends_on)):
raise ValueError(f"duplicate dependency: {feature.name}")
if any(dep not in names for dep in feature.depends_on):
missing = next(dep for dep in feature.depends_on if dep not in names)
raise ValueError(f"unknown feature reference: {missing}")
return self
def validate_finite(value: Any, path: str = "$") -> None:
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"non-finite number at {path}")
if isinstance(value, dict):
for key, item in value.items():
validate_finite(item, f"{path}.{key}")
elif isinstance(value, list):
for index, item in enumerate(value):
validate_finite(item, f"{path}[{index}]")
@@ -0,0 +1,16 @@
"""Prompt material for the model-facing Authoring CDSL contract."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md"
@lru_cache(maxsize=1)
def load_authoring_guidance() -> str:
try:
return _GUIDANCE.read_text(encoding="utf-8")[:12_000]
except OSError:
return "Use cad.author.v1 only. Never create runtime IDs or selector tokens."
@@ -6,62 +6,25 @@ from hashlib import sha256
import json
from typing import Any, Literal
from app.cad_agent.application.llm_contracts import (
EmptyCommand,
ImageObservation,
MarkdownDocument,
StatelessCandidateReview,
StatelessGeometryConclusion,
StatelessRollbackCheckpoint,
StatelessTopologyRequest,
compiled_requirements_schema,
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.application.authoring_contract import AuthoringDocument
from app.cad_agent.application.workflow import RequirementsAnalysis
from app.cad_agent.ports import CadRuntime, ModelGateway
CapabilityRole = Literal["author", "reviewer"]
CapabilityRole = Literal["author"]
def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]:
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)),
]
atomic_ids = list(runtime.supported_atomic_ids())
if not atomic_ids:
if not runtime.supported_atomic_ids():
raise RuntimeError("Runtime has no operations for conformance")
tools = [
_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("inspect_topology", StatelessTopologyRequest.model_json_schema()),
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
_tool("complete_task", EmptyCommand.model_json_schema()),
return [
_tool("analyze_requirements", RequirementsAnalysis.model_json_schema()),
_tool("write_authoring_cdsl", AuthoringDocument.model_json_schema()),
]
for atomic_id in atomic_ids:
contract = runtime.operation_contract(atomic_id)
tools.append(_tool(
f"conformance_{atomic_id}",
fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]),
))
return tools
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
payload = {"protocol": "cad.single-stage.v1", "role": role, "tools": tools}
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -1,412 +0,0 @@
"""Canonical schemas for every v3 LLM state-changing command.
Providers may parse structured output first, but this module validates the raw
tool arguments a second time before a command reaches a handler.
"""
from __future__ import annotations
from copy import deepcopy
import hashlib
import json
import math
from typing import Annotated, Any, Literal, TypeVar
from jsonschema import Draft202012Validator
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
class StrictDto(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
class AcceptanceClaimInput(StrictDto):
claim_kind: Identifier
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
class SpecRequirementInput(StrictDto):
statement: Annotated[str, Field(min_length=1, max_length=1000)]
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
class MarkdownDocument(StrictDto):
"""A frozen human-readable design artifact, never an executable payload."""
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
class CompiledRequirementInput(StrictDto):
"""One verifier bundle for one server-parsed checklist item.
The checklist text, ordering, source bindings, and all identifiers are
intentionally absent: the service owns them after Markdown is frozen.
"""
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
class CompiledRequirementsSpec(StrictDto):
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
# Kept only so an interrupted process with an already imported old tool schema
# fails at the workflow boundary instead of failing module import. New v3.1
# tasks never expose or accept this aggregate specification.
class RequirementsSpec(StrictDto):
outcome: Literal["ready"]
summary: Annotated[str, Field(min_length=1, max_length=2000)]
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
class RequirementsClarification(StrictDto):
outcome: Literal["clarification"]
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
question: Annotated[str, Field(min_length=1, max_length=500)]
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
pass
class EmptyCommand(StrictDto):
pass
class NextAction(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
intent: ShortText
# The server binds this list from every frozen checklist target. It is not
# author input, so a five-item UI-era limit must not reject a valid task.
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
atomic_id: Identifier
expected_change: ShortText
@model_validator(mode="after")
def _requirement_ids_are_unique(self) -> "NextAction":
if len(self.requirement_ids) != len(set(self.requirement_ids)):
raise ValueError("requirement_ids must not contain duplicates")
return self
class StatelessNextAction(StrictDto):
intent: ShortText
operation: Identifier
expected_change: ShortText
class TopologyRequest(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
limit: int = Field(default=16, ge=1, le=64)
class GeometryConclusion(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
decision: Literal["return_to_action_selection", "rollback"]
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
@model_validator(mode="after")
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
if len(self.evidence_refs) != len(set(self.evidence_refs)):
raise ValueError("evidence_refs must not contain duplicates")
return self
class RollbackCheckpoint(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
checkpoint_token: Identifier
reason: Annotated[str, Field(min_length=1, max_length=360)]
class StatelessTopologyRequest(StrictDto):
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
limit: int = Field(default=16, ge=1, le=64)
class StatelessGeometryConclusion(StrictDto):
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
decision: Literal["return_to_action_selection", "rollback"]
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
class StatelessRollbackCheckpoint(StrictDto):
checkpoint_token: Identifier
reason: Annotated[str, Field(min_length=1, max_length=360)]
class ClaimCoverage(StrictDto):
claim_id: Identifier
status: Literal["pass", "pending", "fail", "not_applicable"]
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
@model_validator(mode="after")
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
if len(self.evidence_refs) != len(set(self.evidence_refs)):
raise ValueError("evidence_refs must not contain duplicates")
return self
class CandidateReview(StrictDto):
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class StatelessCandidateReview(StrictDto):
verdict: Literal["accept", "reject"]
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class VisualClaimDecision(StrictDto):
status: Literal["pass", "fail"]
evidence: Annotated[str, Field(min_length=1, max_length=360)]
class StatelessFinalReview(StrictDto):
verdict: Literal["pass", "repair"]
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class ImageMeasurement(StrictDto):
name: Annotated[str, Field(min_length=1, max_length=160)]
value: float | None = None
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
evidence: Annotated[str, Field(min_length=1, max_length=360)]
confidence: float = Field(ge=0, le=1)
class ImageObservation(StrictDto):
summary: Annotated[str, Field(min_length=1, max_length=2000)]
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
class FinalReview(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
schema = RequirementsAuthorOutput.model_json_schema()
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
if isinstance(requirement, dict):
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
return schema
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
schema = CompiledRequirementsSpec.model_json_schema()
definitions = schema.get("$defs", {})
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
if isinstance(requirement, dict):
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
requirements = schema.get("properties", {}).get("requirements")
if isinstance(requirements, dict):
requirements["minItems"] = target_count
requirements["maxItems"] = target_count
return schema
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
"""Drop harmless compiler chatter before strict requirements validation.
``compile_requirements_spec`` is a compiler stage: the service only needs
the ordered verifier bundles for the frozen checklist items. Real models
sometimes add explanatory fields such as a top-level ``assumptions`` or
per-item ``statement`` even when the dynamic tool schema forbids them. Those
fields are not executable and are not part of the frozen contract, so they
should not abort a task before modeling starts.
The verifier ``expected`` payload is intentionally not sanitized here. It
remains governed by the registry's strict per-claim schema because those
values drive deterministic validation.
"""
value = canonical_json_object(raw_arguments_json)
if isinstance(value, WorkflowError):
return value
requirements = value.get("requirements")
sanitized: dict[str, Any] = {}
if isinstance(requirements, list):
sanitized_requirements: list[Any] = []
for requirement in requirements:
if not isinstance(requirement, dict):
sanitized_requirements.append(requirement)
continue
item: dict[str, Any] = {}
if "assumptions" in requirement:
item["assumptions"] = requirement["assumptions"]
if "acceptance_claims" in requirement:
claims = requirement["acceptance_claims"]
if isinstance(claims, list):
item["acceptance_claims"] = [
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
if isinstance(claim, dict) else claim
for claim in claims
]
else:
item["acceptance_claims"] = claims
sanitized_requirements.append(item)
sanitized["requirements"] = sanitized_requirements
else:
sanitized["requirements"] = requirements
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
schema = StatelessNextAction.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["operation"] = {"enum": atomic_ids}
return schema
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
schema = StatelessFinalReview.model_json_schema()
properties = schema.get("properties", {})
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
if isinstance(visual, dict):
visual["minItems"] = visual_claim_count
visual["maxItems"] = visual_claim_count
return schema
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
schema = StatelessRollbackCheckpoint.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
return schema
def topology_request_schema(working_head: str) -> dict[str, Any]:
schema = TopologyRequest.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
return schema
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
"""Bind a rollback request to immutable checkpoints in the active lineage."""
schema = RollbackCheckpoint.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
return schema
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
definitions = schema.get("$defs", {})
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
if not isinstance(coverage, dict):
return
properties = coverage.get("properties", {})
if isinstance(properties, dict):
properties["claim_id"] = {"enum": claim_ids}
Dto = TypeVar("Dto", bound=StrictDto)
def raw_arguments_hash(raw_arguments_json: str) -> str:
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
def json_depth(value: Any, current: int = 0) -> int:
if isinstance(value, dict):
return max([current, *(json_depth(item, current + 1) for item in value.values())])
if isinstance(value, list):
return max([current, *(json_depth(item, current + 1) for item in value)])
return current
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
"""Bound and parse raw arguments before any schema-specific validation."""
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
try:
value = json.loads(raw_arguments_json)
except json.JSONDecodeError as error:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
if not isinstance(value, dict):
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
if json_depth(value) > max_depth:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
if _contains_non_finite_number(value):
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
return value
def _contains_non_finite_number(value: Any) -> bool:
if isinstance(value, float):
return not math.isfinite(value)
if isinstance(value, dict):
return any(_contains_non_finite_number(item) for item in value.values())
if isinstance(value, list):
return any(_contains_non_finite_number(item) for item in value)
return False
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
"""Revalidate raw arguments against the current dynamic JSON Schema."""
value = canonical_json_object(raw_arguments_json)
if isinstance(value, WorkflowError):
return value
errors = [
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
]
if errors:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
return None
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
"""Parse raw tool arguments once and return field-level DTO failures safely."""
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
if isinstance(value, WorkflowError):
return value
try:
return model.model_validate(value)
except ValidationError as error:
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
"""Require exactly one known tool call; no provider parser is trusted."""
if len(tool_calls) != 1:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
name = str(function.get("name") or "") if isinstance(function, dict) else ""
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
if name != allowed_name:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
return name, raw
@@ -1,670 +0,0 @@
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
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
from app.cad_agent.domain.verifier_registry import VerifierRegistry
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:
"""Persist frozen documents and compile their checklist into a contract.
The model never names targets or internal objects during compilation. The
service derives those values strictly from the immutable checklist.
"""
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]]] = {}
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
self._evaluation_capability_gaps[task_id] = [
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
for item in validation_capability_gaps or () if isinstance(item, dict)
]
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
claims = self._evaluation_contract_oracles.get(task_id)
return None if claims is None else {
"evaluation_only": True,
"required_claims": deepcopy(claims),
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
}
@staticmethod
def document_schema() -> dict[str, Any]:
return MarkdownDocument.model_json_schema()
def compiler_schema(self, task_id: str) -> dict[str, Any]:
return compiled_requirements_schema(
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
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)
def submit_completion_target(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_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:
# 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)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
targets = self._checklist_items(task_id)
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),
*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")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
source_ids = list(self.artifacts.read_source_index(task_id))
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
warnings = [
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
*compiler_warnings,
]
requirements: list[dict[str, Any]] = []
claim_position = 1
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
claims: list[dict[str, Any]] = []
for claim in compiled.acceptance_claims:
definition = self.registry.definition(claim.claim_kind)
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
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.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)
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
return Rejected(self._stale())
return result
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
visual = iter(review.get("visual_claims") or ())
rows = ["# Completion Result", "", "## Checklist", ""]
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
statuses: list[str] = []
evidence: list[str] = []
for claim in requirement.get("acceptance_claims") or ():
if not isinstance(claim, dict):
continue
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
statuses.append(str(result.get("status") or "unknown"))
value = result.get("evidence")
if value:
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
rows.extend(f" - Evidence: {value}" for value in evidence)
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
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 != phase:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
errors = validator(document.markdown)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
except OSError as error:
return self._park_for_storage_retry(state, str(error))
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
next_state = transition(state, event, **kwargs)
result = Accepted({"phase": next_state.phase.value, "path": written})
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
return Rejected(self._stale())
return result
def _checklist_items(self, task_id: str) -> list[str]:
path = self.artifacts.task_dir(task_id) / "completion-target.md"
text = path.read_text(encoding="utf-8") if path.is_file() else ""
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
for requirement_index, requirement in enumerate(output.requirements):
for claim_index, claim in enumerate(requirement.acceptance_claims):
try:
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
except ValueError:
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
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."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
warnings.append(
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
)
continue
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
normalized_claims.append(claim)
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
"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":
return False
if RequirementsCommandHandler._target_describes_finished_envelope(target):
return False
return any(
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
for item in claims
)
@staticmethod
def _target_describes_finished_envelope(target: str) -> bool:
lowered = target.lower()
return any(token in lowered for token in (
"overall",
"total",
"finished part",
"entire part",
"whole part",
"bounding box",
"envelope",
"总",
"整体",
"成品",
"全高",
"包围盒",
))
@staticmethod
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
value = bbox_claim.expected.get("value")
if not isinstance(value, (int, float)):
return
for item in claims:
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
continue
expected = getattr(item, "expected", None)
if not isinstance(expected, dict) or "axial_span_mm" in expected:
continue
expected["axial_span_mm"] = value
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
return
@staticmethod
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
# Markdown is a human-facing semantic artifact. Its content is frozen
# verbatim and is not executable, so headings are guidance for the
# author rather than a server-enforced protocol.
return []
@staticmethod
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
errors: list[dict[str, str]] = []
if not values:
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
if len(values) != len(set(values)):
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
return errors
@staticmethod
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
return []
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
invocation = self.repository.get_invocation(task_id, invocation_id)
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
@staticmethod
def _restore(payload: dict[str, Any]) -> Accepted:
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
@staticmethod
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
@staticmethod
def _stale() -> WorkflowError:
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
@@ -1,29 +0,0 @@
"""Typed results returned by all command handlers."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from app.cad_agent.domain.errors import WorkflowError
@dataclass(frozen=True, slots=True)
class Accepted:
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class Rejected:
error: WorkflowError
@dataclass(frozen=True, slots=True)
class Waiting:
error: WorkflowError
@dataclass(frozen=True, slots=True)
class FailedInternal:
correlation_id: str
message: str
@@ -0,0 +1,99 @@
"""Deterministic execution service for the single-stage Authoring protocol.
The service is deliberately synchronous and side-effect bounded so the HTTP
workflow can call it after one author response (and at most two repairs).
"""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any
from app.cad_agent.ports import AdapterUnavailable
class SingleStageExecutor:
MAX_REPAIRS = 2
def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None:
self.repository, self.artifacts, self.runtime = repository, artifacts, runtime
def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
"""Compile once and persist immutable compiler inputs and output."""
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
raise ValueError("repair budget exhausted")
runtime_cdsl, audit = self.runtime.compile_authoring(authoring)
digest = self._digest(authoring)
attempt = repair_count + 1
runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json"
audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json"
self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl)
self.artifacts.write_json_once(task_id, audit_path, audit)
return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest}
def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]:
"""Build one compiled CDSL document and publish any executable prefix."""
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
raise ValueError("repair budget exhausted")
digest = digest or self._digest(authoring)
stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", {
"schema_version": "cad.single-stage.v1", "authoring": authoring,
"runtime": runtime_cdsl, "compile_audit": audit,
})
try:
rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id)
except (OSError, AdapterUnavailable):
raise
except Exception as error:
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
"schema_version": "cad.build-diagnostics.v1",
"diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}],
})
return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit}
diagnostics = self._decorate_diagnostics(failures, audit)
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
"schema_version": "cad.build-diagnostics.v1",
"diagnostics": diagnostics,
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
})
if rebuilt:
revision = f"rev_{digest}"
self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", {
"schema_version": "cad.single-stage.staging-manifest.v1",
"stage_id": stage.stage_id,
"revision_id": revision,
"repair_count": repair_count,
"source_sha256": audit.get("source_sha256", ""),
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
})
paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision)
else:
paths = {}
return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit}
def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
"""Compatibility convenience for direct callers and focused tests."""
compiled = self.compile(task_id, authoring, repair_count=repair_count)
return {
**self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]),
"runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"],
}
@staticmethod
def _digest(authoring: dict[str, Any]) -> str:
encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
return sha256(encoded).hexdigest()[:16]
@staticmethod
def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]:
names = {
str(feature_id): str(name)
for name, feature_id in (audit.get("feature_ids") or {}).items()
if isinstance(name, str) and isinstance(feature_id, str)
}
result: list[dict[str, Any]] = []
for failure in failures:
if not isinstance(failure, dict):
continue
feature_id = str(failure.get("feature_id") or "")
result.append({**failure, "feature_name": names.get(feature_id, "")})
return result
File diff suppressed because it is too large Load Diff