From 97a03c290bd76740a690db4bd286af4522322e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Tue, 1 Sep 2026 16:37:27 +0800 Subject: [PATCH] chore: save current work --- .../app/cad_agent/adapters/artifact_store.py | 59 +- .../app/cad_agent/adapters/review_gateway.py | 8 +- .../cad_agent/adapters/sqlite_repository.py | 44 +- .../cad_agent/application/action_handlers.py | 2 +- .../app/cad_agent/application/capabilities.py | 121 +- .../cad_agent/application/llm_contracts.py | 226 +- .../cad_agent/application/normalization.py | 49 - .../app/cad_agent/application/requirements.py | 320 ++ .../application/requirements_review.py | 665 --- backend/app/cad_agent/application/workflow.py | 574 ++- backend/app/cad_agent/composition.py | 3 +- backend/app/cad_agent/domain/errors.py | 3 +- backend/app/cad_agent/domain/state.py | 26 +- backend/app/cad_agent/evals/live.py | 33 +- backend/app/cad_agent/ports.py | 8 +- backend/app/main.py | 16 +- backend/app/services/agent_service.py | 203 +- backend/tests/test_cad_agent_v3.py | 4033 ++--------------- frontend/src/components/cad-message-parts.tsx | 14 +- frontend/src/lib/cad-artifacts.ts | 1 - frontend/src/lib/cad-messages.ts | 1 - frontend/src/lib/cad-stream.test.ts | 18 +- frontend/src/lib/cad-stream.ts | 8 +- frontend/src/lib/cad-types.ts | 18 +- 24 files changed, 1380 insertions(+), 5073 deletions(-) delete mode 100644 backend/app/cad_agent/application/normalization.py create mode 100644 backend/app/cad_agent/application/requirements.py delete mode 100644 backend/app/cad_agent/application/requirements_review.py diff --git a/backend/app/cad_agent/adapters/artifact_store.py b/backend/app/cad_agent/adapters/artifact_store.py index 230d395c..4267a0d5 100644 --- a/backend/app/cad_agent/adapters/artifact_store.py +++ b/backend/app/cad_agent/adapters/artifact_store.py @@ -39,6 +39,7 @@ class FileArtifactStore: request: str, *, source_blocks: list[dict[str, Any]] | None = None, + image_inputs: list[dict[str, str]] | None = None, ) -> None: root = self.task_dir(task_id) (root / "documents").mkdir(parents=True, exist_ok=True) @@ -50,6 +51,21 @@ class FileArtifactStore: if not source.exists(): self._write_once(source, "\n\n".join(block["text"] for block in blocks) + "\n") self.write_source_index(task_id, request, source_blocks=blocks) + images: list[dict[str, str]] = [] + for position, item in enumerate(image_inputs or (), 1): + source_path = Path(str(item.get("path") or "")).resolve() + digest = str(item.get("sha256") or "") + if not source_path.is_file() or not re.fullmatch(r"[a-f0-9]{64}", digest): + raise ValueError("Image input is unavailable or has no valid checksum") + data = source_path.read_bytes() + if sha256(data).hexdigest() != digest: + raise ValueError("Image input checksum mismatch") + suffix = source_path.suffix.lower() if source_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"} else ".bin" + relative = f"inputs/reference-{position:03d}{suffix}" + self._write_bytes_once(self._path(task_id, relative), data) + images.append({"path": relative, "mime": str(item.get("mime") or "image/*"), "sha256": digest}) + if images: + self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images}) def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: """Mirror committed SQLite events into an append-only JSONL audit log. @@ -171,30 +187,16 @@ class FileArtifactStore: path = self.task_dir(task_id) / "source-requirements.md" return path.read_text(encoding="utf-8") if path.is_file() else "" - def read_requirements_draft(self, task_id: str, artifact_path: str = "") -> dict[str, Any]: - if artifact_path: - value = self.read_json(task_id, artifact_path) - if value is None: - raise ValueError("Committed requirements draft artifact is unavailable") - return value - documents = self.task_dir(task_id) / "documents" - candidates = sorted(documents.glob("requirements-draft-v*.json")) - if not candidates: - return {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []} - value = json.loads(candidates[-1].read_text(encoding="utf-8")) - return value if isinstance(value, dict) else {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []} + def source_image_paths(self, task_id: str) -> list[str]: + manifest = self.read_json(task_id, "documents/source-images.json") or {} + return [ + str(self._path(task_id, str(item.get("path") or ""))) + for item in manifest.get("images") or () + if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file() + ] - def write_requirements_draft(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str: - revision = int(payload.get("revision") or 0) - if revision < 1: - raise ValueError("Requirements draft revision is invalid") - return self._write_invocation_json(task_id, f"requirements-draft-v{revision}", payload, invocation_id) - - def read_requirements_review(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: - return self.read_json(task_id, artifact_path) if artifact_path else None - - def write_requirements_review(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str: - return self._write_invocation_json(task_id, "requirements-review", payload, invocation_id) + def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: + return self.read_json(task_id, artifact_path or "documents/requirements-spec.json") def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: # State points to the immutable artifact used as program input. The @@ -338,6 +340,17 @@ class FileArtifactStore: temporary.write_bytes(encoded) os.replace(temporary, path) + @staticmethod + def _write_bytes_once(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + if path.read_bytes() != data: + raise ValueError(f"Immutable artifact already exists: {path.name}") + return + temporary = path.with_name(path.name + ".tmp-" + secrets.token_hex(4)) + temporary.write_bytes(data) + os.replace(temporary, path) + def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str: digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12] safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32] diff --git a/backend/app/cad_agent/adapters/review_gateway.py b/backend/app/cad_agent/adapters/review_gateway.py index adc8ac5e..6711d323 100644 --- a/backend/app/cad_agent/adapters/review_gateway.py +++ b/backend/app/cad_agent/adapters/review_gateway.py @@ -19,7 +19,13 @@ class RenderedReviewGateway: name = str((tool.get("function") or {}).get("name") or "") if not name: raise RuntimeError("Review tool is missing a name") - content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}] + public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"} + content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}] + if kind in {"image_observation", "final"}: + for raw_path in payload.get("reference_image_paths") or (): + path = Path(str(raw_path)) + if path.is_file(): + content.append(self._image_part(path)) if kind in {"candidate", "final"}: manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {} for path in self._evidence_paths(manifest): diff --git a/backend/app/cad_agent/adapters/sqlite_repository.py b/backend/app/cad_agent/adapters/sqlite_repository.py index 07ce7422..30d0e948 100644 --- a/backend/app/cad_agent/adapters/sqlite_repository.py +++ b/backend/app/cad_agent/adapters/sqlite_repository.py @@ -49,8 +49,8 @@ class SqliteTaskRepository: repair_required INTEGER NOT NULL DEFAULT 0, last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '', - requirements_draft_path TEXT NOT NULL DEFAULT '', - requirements_review_path TEXT NOT NULL DEFAULT '', + requirements_spec_path TEXT NOT NULL DEFAULT '', + clarification_path TEXT NOT NULL DEFAULT '', requirements_contract_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -100,15 +100,6 @@ class SqliteTaskRepository: ); """ ) - columns = {str(row["name"]) for row in connection.execute("PRAGMA table_info(tasks)").fetchall()} - if "requirements_draft_path" not in columns: - connection.execute("ALTER TABLE tasks ADD COLUMN requirements_draft_path TEXT NOT NULL DEFAULT ''") - if "requirements_review_path" not in columns: - connection.execute("ALTER TABLE tasks ADD COLUMN requirements_review_path TEXT NOT NULL DEFAULT ''") - if "requirements_contract_path" not in columns: - connection.execute("ALTER TABLE tasks ADD COLUMN requirements_contract_path TEXT NOT NULL DEFAULT ''") - if "retry_from_phase" not in columns: - connection.execute("ALTER TABLE tasks ADD COLUMN retry_from_phase TEXT NOT NULL DEFAULT ''") def create_task(self, task_id: str, request: str) -> TaskState: with self._lock, self._connection() as connection: @@ -143,21 +134,17 @@ class SqliteTaskRepository: verification_warnings = [ str(item) for item in frozen.get("verification_warnings") or () if str(item) ] if isinstance(frozen, dict) else [] - applied_normalizations = [ - item for item in frozen.get("applied_normalizations") or () if isinstance(item, dict) - ] if isinstance(frozen, dict) else [] status_event = next(( item for item in reversed(events) if item.get("event") in { "requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted", - "requirements_review_limit_reached", "no_progress_limit", + "no_progress_limit", "candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure", "failed_author_format", "runtime_contract_invalid", } ), {}) if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {} questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else [] - findings = [item for item in status_event.get("findings") or () if isinstance(item, dict)] if isinstance(status_event, dict) else [] - issues = [str(item.get("description") or "") for item in findings if item.get("description")] + issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else [] return { "schema_version": "3.0", "task_id": state.task_id, @@ -172,12 +159,15 @@ class SqliteTaskRepository: "repair_required": state.repair_required, "last_error": state.last_error.value if state.last_error else "", "retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "", - "requirements_draft_path": state.requirements_draft_path, - "requirements_review_path": state.requirements_review_path, + "requirements_spec_path": state.requirements_spec_path, + "clarification_path": state.clarification_path, "requirements_contract_path": state.requirements_contract_path, - "verification_status": "completed_with_risks" if verification_warnings else "verified", + "verification_status": ( + "completed_with_risks" if state.phase == TaskPhase.COMPLETED and verification_warnings + else "verified" if state.phase == TaskPhase.COMPLETED + else "pending" + ), "verification_warnings": verification_warnings, - "applied_normalizations": applied_normalizations, "message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "", "questions": questions, "issues": issues, @@ -228,16 +218,16 @@ class SqliteTaskRepository: try: cursor = connection.execute( """UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?, - candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_draft_path = ?, - requirements_review_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP + candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?, + clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ? AND state_version = ?""", ( state.phase.value, state.version, state.active_revision, json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None, state.candidate_id, state.candidate_stage_id, int(state.repair_required), state.last_error.value if state.last_error else None, - state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_draft_path, - state.requirements_review_path, state.requirements_contract_path, + state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path, + state.clarification_path, state.requirements_contract_path, state.task_id, previous_version, ), ) @@ -386,8 +376,8 @@ class SqliteTaskRepository: repair_required=bool(row["repair_required"]), last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None, retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None, - requirements_draft_path=str(row["requirements_draft_path"] or ""), - requirements_review_path=str(row["requirements_review_path"] or ""), + requirements_spec_path=str(row["requirements_spec_path"] or ""), + clarification_path=str(row["clarification_path"] or ""), requirements_contract_path=str(row["requirements_contract_path"] or ""), ) diff --git a/backend/app/cad_agent/application/action_handlers.py b/backend/app/cad_agent/application/action_handlers.py index 8cc5b09d..47299aa3 100644 --- a/backend/app/cad_agent/application/action_handlers.py +++ b/backend/app/cad_agent/application/action_handlers.py @@ -914,7 +914,7 @@ class ActionCommandHandler: return Accepted(result) next_state = transition(state, "final_accepted") result = {"status": "completed", "revision_id": state.active_revision} - if not self._commit_invocation(next_state, [{"event": "completed", "revision_id": state.active_revision, "final_review_path": final_review_path, "claim_results": claim_results}], invocation, result): + if not self._commit_invocation(next_state, [{"event": "completed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result): return Rejected(self._stale()) return Accepted(result) diff --git a/backend/app/cad_agent/application/capabilities.py b/backend/app/cad_agent/application/capabilities.py index 97ded153..25bd7906 100644 --- a/backend/app/cad_agent/application/capabilities.py +++ b/backend/app/cad_agent/application/capabilities.py @@ -1,83 +1,98 @@ -"""Real provider structured-output conformance gate for protocol v3.""" +"""Cached, role-scoped structured-output conformance checks.""" from __future__ import annotations from hashlib import sha256 import json -from typing import Any +from typing import Any, Literal from app.cad_agent.application.llm_contracts import ( EmptyCommand, - GeometryConclusion, - RollbackCheckpoint, - candidate_review_schema, - final_review_schema, - geometry_conclusion_schema, - next_action_schema, - operation_contract_request_schema, - requirements_draft_schema, - requirements_patch_schema, - requirements_review_schema, - rollback_checkpoint_schema, - topology_request_schema, + ImageObservation, + RequirementsAuthorOutput, + StatelessCandidateReview, + StatelessGeometryConclusion, + StatelessRollbackCheckpoint, + StatelessTopologyRequest, + requirements_spec_schema, + stateless_final_review_schema, + stateless_next_action_schema, ) from app.cad_agent.domain.operation_contract import fragment_schema from app.cad_agent.domain.verifier_registry import default_registry from app.cad_agent.ports import CadRuntime, ModelGateway -_CONFORMANCE_WORKING_HEAD = "cad_conformance:root:v1" -_CONFORMANCE_REQUIREMENT_IDS = ["req_001"] -_CONFORMANCE_CLAIM_IDS = ["claim_001"] -_CONFORMANCE_DRAFT_IDS = ["draft_001"] -_CONFORMANCE_SOURCE_IDS = ["src_001"] +CapabilityRole = Literal["author", "reviewer"] -def conformance_tools(runtime: CadRuntime) -> list[dict[str, Any]]: - """Return the complete v3 structured-output surface. - - A provider is usable only when it can return valid arguments for every - fixed schema and every currently registered runtime operation. This list - intentionally derives the operation portion from the runtime registry so - a newly exposed operation cannot bypass the capability gate. - """ - registry = default_registry() +def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]: + if role == "reviewer": + return [ + _tool("observe_images", ImageObservation.model_json_schema()), + _tool("review_candidate", StatelessCandidateReview.model_json_schema()), + _tool("review_final", stateless_final_review_schema(1)), + ] atomic_ids = list(runtime.supported_atomic_ids()) - tools = [ - _tool("submit_requirements_draft_batch", requirements_draft_schema(registry.expected_one_of_schema(), _CONFORMANCE_SOURCE_IDS)), - _tool("patch_requirements_draft", requirements_patch_schema(registry.expected_one_of_schema(), _CONFORMANCE_SOURCE_IDS, _CONFORMANCE_DRAFT_IDS)), - _tool("finalize_requirements_draft", EmptyCommand.model_json_schema()), - _tool("review_requirements", requirements_review_schema(_CONFORMANCE_SOURCE_IDS, _CONFORMANCE_DRAFT_IDS)), - _tool("propose_next_action", next_action_schema(_CONFORMANCE_WORKING_HEAD, _CONFORMANCE_REQUIREMENT_IDS, atomic_ids)), - _tool("inspect_topology", topology_request_schema(_CONFORMANCE_WORKING_HEAD)), - _tool("get_cdsl_operation_contract", operation_contract_request_schema(_CONFORMANCE_WORKING_HEAD, atomic_ids[0])), - _tool("review_candidate", candidate_review_schema("candidate_conformance", _CONFORMANCE_WORKING_HEAD, _CONFORMANCE_CLAIM_IDS)), - _tool("complete_task", EmptyCommand.model_json_schema()), - _tool("review_final", final_review_schema(_CONFORMANCE_WORKING_HEAD, _CONFORMANCE_CLAIM_IDS)), - _tool("record_geometry_conclusion", geometry_conclusion_schema(_CONFORMANCE_WORKING_HEAD, ["evidence_current_state"])), - _tool("rollback_checkpoint", rollback_checkpoint_schema(_CONFORMANCE_WORKING_HEAD, ["checkpoint_root"])), - ] if not atomic_ids: raise RuntimeError("Runtime has no operations for conformance") + tools = [ + _tool("submit_requirements_spec", requirements_spec_schema(default_registry().expected_one_of_schema())), + _tool("propose_next_action", stateless_next_action_schema(atomic_ids)), + _tool("inspect_topology", StatelessTopologyRequest.model_json_schema()), + _tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()), + _tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()), + _tool("complete_task", EmptyCommand.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"]))) + 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]]) -> str: - return sha256(json.dumps(tools, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() +def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str: + payload = {"protocol": "cad.v3.spec.v1", "role": role, "tools": tools} + return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() -async def verify_model_capability(repository: Any, runtime: CadRuntime, models: ModelGateway, *, provider_id: str, model_id: str, force: bool = False) -> dict[str, Any]: - tools = conformance_tools(runtime) - schema_hash = conformance_hash(tools) +def cached_model_capability( + repository: Any, + runtime: CadRuntime, + *, + provider_id: str, + model_id: str, + role: CapabilityRole, +) -> dict[str, Any] | None: + tools = conformance_tools(runtime, role=role) + schema_hash = conformance_hash(tools, role=role) cached = repository.model_capability(provider_id, model_id, schema_hash) - if cached is not None and cached["supported"] and not force: - return {"cached": True, "schema_hash": schema_hash, **cached["report"]} + if cached is None: + return None + return {"cached": True, "schema_hash": schema_hash, "role": role, **cached["report"]} + + +async def verify_model_capability( + repository: Any, + runtime: CadRuntime, + models: ModelGateway, + *, + provider_id: str, + model_id: str, + role: CapabilityRole, + force: bool = False, +) -> dict[str, Any]: + tools = conformance_tools(runtime, role=role) + schema_hash = conformance_hash(tools, role=role) + cached = repository.model_capability(provider_id, model_id, schema_hash) + if cached is not None and not force: + return {"cached": True, "schema_hash": schema_hash, "role": role, **cached["report"]} report = await models.conformance(provider_id=provider_id, model_id=model_id, tools=tools) - report = {"schema_hash": schema_hash, "tool_count": len(tools), **report} - repository.record_model_capability(provider_id, model_id, schema_hash, report) + report = {"schema_hash": schema_hash, "role": role, "tool_count": len(tools), **report} + if not report.get("probe_unavailable"): + repository.record_model_capability(provider_id, model_id, schema_hash, report) return report @@ -86,7 +101,7 @@ def _tool(name: str, parameters: dict[str, Any]) -> dict[str, Any]: "type": "function", "function": { "name": name, - "description": "Structured output conformance probe. Return one schema-valid call with every required root and nested property.", + "description": "Structured output conformance probe. Return one schema-valid call.", "parameters": parameters, }, } diff --git a/backend/app/cad_agent/application/llm_contracts.py b/backend/app/cad_agent/application/llm_contracts.py index 5cb34b20..712f7344 100644 --- a/backend/app/cad_agent/application/llm_contracts.py +++ b/backend/app/cad_agent/application/llm_contracts.py @@ -13,7 +13,7 @@ import math from typing import Annotated, Any, Literal, TypeVar from jsonschema import Draft202012Validator -from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, model_validator +from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator from app.cad_agent.domain.errors import ErrorCode, WorkflowError @@ -31,84 +31,33 @@ class AcceptanceClaimInput(StrictDto): expected: dict[str, JsonValue] = Field(min_length=0, max_length=24) -class RequirementInput(StrictDto): - source_ids: list[Identifier] = Field(min_length=1, max_length=32) +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) - @model_validator(mode="after") - def _source_ids_are_unique(self) -> "RequirementInput": - if len(self.source_ids) != len(set(self.source_ids)): - raise ValueError("source_ids must not contain duplicates") - return self + +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 RequirementsDraftBatch(StrictDto): - items: list[RequirementInput] = Field(min_length=1, max_length=8) +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 RequirementsPatch(StrictDto): - target_draft_id: Identifier = Field(description="Current server-assigned draft ID to patch. This field belongs inside one patches[] entry.") - op: Literal["replace", "remove"] = Field(description="replace supplies a complete replacement item; remove supplies a reason instead.") - item: RequirementInput | None = Field(default=None, description="Complete replacement RequirementInput for op=replace. It must not include draft_id because the server preserves that ID.") - reason: str | None = Field(default=None, min_length=1, max_length=360, description="Required only for op=remove; explains why the current draft item is removed.") - - @model_validator(mode="after") - def _complete_patch(self) -> "RequirementsPatch": - if self.op == "replace" and self.item is None: - raise ValueError("replace requires a complete item") - if self.op == "remove" and (self.item is not None or self.reason is None): - raise ValueError("remove requires a reason and forbids item") - return self - - -class RequirementsPatchBatch(StrictDto): - patches: list[RequirementsPatch] = Field(min_length=1, max_length=8, description="Patch entries. Example shape: {\"patches\":[{\"target_draft_id\":\"draft_001\",\"op\":\"replace\",\"item\":{...}}]}.") +class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]): + pass class EmptyCommand(StrictDto): pass -class ReviewNormalization(StrictDto): - rule_id: Literal["full_circle_equal_spacing"] - count: int = Field(ge=2, le=1024) - declared_spacing_degrees: float = Field(gt=0, le=360) - full_circle: bool - - -class ReviewFinding(StrictDto): - draft_id: Identifier - source_ids: list[Identifier] = Field(min_length=1, max_length=32) - finding_type: Literal[ - "missing_source_semantics", - "claim_mismatch", - "verification_gap", - "derivable_conflict", - "ambiguous_conflict", - ] - description: Annotated[str, Field(min_length=1, max_length=1000)] - question: str | None = Field(default=None, min_length=1, max_length=360) - normalization: ReviewNormalization | None = None - - @model_validator(mode="after") - def _finding_payload_matches_type(self) -> "ReviewFinding": - if len(self.source_ids) != len(set(self.source_ids)): - raise ValueError("source_ids must not contain duplicates") - if self.finding_type == "ambiguous_conflict" and self.question is None: - raise ValueError("ambiguous_conflict requires an answerable question") - if self.finding_type == "derivable_conflict" and self.normalization is None: - raise ValueError("derivable_conflict requires structured normalization data") - if self.finding_type != "derivable_conflict" and self.normalization is not None: - raise ValueError("normalization is allowed only for derivable_conflict") - return self - - -class RequirementsReview(StrictDto): - findings: list[ReviewFinding] = Field(default_factory=list, max_length=128) - - class NextAction(StrictDto): working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] intent: ShortText @@ -123,17 +72,18 @@ class NextAction(StrictDto): 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 OperationContractRequest(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - atomic_id: Identifier - - 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) @@ -154,6 +104,22 @@ class RollbackCheckpoint(StrictDto): 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"] @@ -175,6 +141,41 @@ class CandidateReview(StrictDto): 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.") @@ -183,85 +184,39 @@ class FinalReview(StrictDto): issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) -def requirements_draft_schema(claim_one_of: dict[str, Any], source_ids: list[str]) -> dict[str, Any]: - """Bind claim and source enums for the current requirements snapshot.""" - schema = RequirementsDraftBatch.model_json_schema() - requirement = schema.get("$defs", {}).get("RequirementInput") +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): - properties = requirement.get("properties", {}) - source_items = properties.get("source_ids", {}).get("items") if isinstance(properties, dict) and isinstance(properties.get("source_ids"), dict) else None - if isinstance(source_items, dict): - source_items["enum"] = source_ids claims = requirement.get("properties", {}).get("acceptance_claims") if isinstance(claims, dict): claims["items"] = deepcopy(claim_one_of) return schema -def requirements_patch_schema(claim_one_of: dict[str, Any], source_ids: list[str], draft_ids: list[str]) -> dict[str, Any]: - schema = RequirementsPatchBatch.model_json_schema() - definitions = schema.get("$defs", {}) - requirement = definitions.get("RequirementInput") if isinstance(definitions, dict) else None - if isinstance(requirement, dict): - properties = requirement.get("properties", {}) - source_items = properties.get("source_ids", {}).get("items") if isinstance(properties, dict) and isinstance(properties.get("source_ids"), dict) else None - if isinstance(source_items, dict): - source_items["enum"] = source_ids - claims = requirement.get("properties", {}).get("acceptance_claims") - if isinstance(claims, dict): - claims["items"] = deepcopy(claim_one_of) - patch = definitions.get("RequirementsPatch") if isinstance(definitions, dict) else None - if isinstance(patch, dict): - target = patch.get("properties", {}).get("target_draft_id") - if isinstance(target, dict): - target["enum"] = draft_ids - return schema - - -def requirements_review_schema(source_ids: list[str], draft_ids: list[str]) -> dict[str, Any]: - schema = RequirementsReview.model_json_schema() - definitions = schema.get("$defs", {}) - finding = definitions.get("ReviewFinding") if isinstance(definitions, dict) else None - if isinstance(finding, dict): - properties = finding.get("properties", {}) - if isinstance(properties.get("draft_id"), dict): - properties["draft_id"] = {"enum": draft_ids} - source_items = properties.get("source_ids", {}).get("items") if isinstance(properties.get("source_ids"), dict) else None - if isinstance(source_items, dict): - source_items.clear() - source_items.update({"enum": source_ids}) - return schema - - -def next_action_schema(working_head: str, requirement_ids: list[str], atomic_ids: list[str]) -> dict[str, Any]: - schema = NextAction.model_json_schema() +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["working_head"] = {"const": working_head} - if isinstance(properties.get("requirement_ids"), dict): - properties["requirement_ids"]["items"] = {"enum": requirement_ids} - properties["atomic_id"] = {"enum": atomic_ids} + properties["operation"] = {"enum": atomic_ids} return schema -def candidate_review_schema(candidate_id: str, working_head: str, claim_ids: list[str]) -> dict[str, Any]: - """Bind an independent candidate review to immutable candidate facts.""" - schema = CandidateReview.model_json_schema() +def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]: + schema = StatelessFinalReview.model_json_schema() properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["candidate_id"] = {"const": candidate_id} - properties["working_head"] = {"const": working_head} - _bind_claim_coverage_ids(schema, claim_ids) + 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 final_review_schema(working_head: str, claim_ids: list[str]) -> dict[str, Any]: - """Bind final review output to the currently reviewable revision.""" - schema = FinalReview.model_json_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["working_head"] = {"const": working_head} - _bind_claim_coverage_ids(schema, claim_ids) + properties["checkpoint_token"] = {"enum": checkpoint_tokens} return schema @@ -273,15 +228,6 @@ def topology_request_schema(working_head: str) -> dict[str, Any]: return schema -def operation_contract_request_schema(working_head: str, atomic_id: str) -> dict[str, Any]: - schema = OperationContractRequest.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["working_head"] = {"const": working_head} - properties["atomic_id"] = {"const": atomic_id} - return schema - - def geometry_conclusion_schema(working_head: str, evidence_refs: list[str]) -> dict[str, Any]: """Bind a diagnostic conclusion to evidence generated for this head.""" schema = GeometryConclusion.model_json_schema() diff --git a/backend/app/cad_agent/application/normalization.py b/backend/app/cad_agent/application/normalization.py deleted file mode 100644 index 05e1b14c..00000000 --- a/backend/app/cad_agent/application/normalization.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Versioned, deterministic requirement-conflict resolutions.""" - -from __future__ import annotations - -import math -from typing import Any - - -REGISTRY_VERSION = "cad.resolution-registry.v1" -FULL_CIRCLE_EQUAL_SPACING_VERSION = "full_circle_equal_spacing.v1" - - -class NormalizationError(ValueError): - pass - - -def resolve_normalization( - *, - task_id: str, - draft_id: str, - source_ids: list[str], - description: str, - value: dict[str, Any], -) -> dict[str, Any]: - if value.get("rule_id") != "full_circle_equal_spacing": - raise NormalizationError("Unknown deterministic normalization rule.") - count = value.get("count") - declared = value.get("declared_spacing_degrees") - if not isinstance(count, int) or isinstance(count, bool) or count < 2: - raise NormalizationError("Full-circle equal spacing requires an integer count of at least 2.") - if value.get("full_circle") is not True: - raise NormalizationError("Automatic equal-spacing normalization is allowed only for a full circle.") - if not isinstance(declared, (int, float)) or isinstance(declared, bool) or not math.isfinite(float(declared)): - raise NormalizationError("Declared angular spacing must be finite.") - adopted = 360.0 / count - if math.isclose(float(declared), adopted, rel_tol=0.0, abs_tol=1e-9): - raise NormalizationError("The declared spacing already matches 360/count; there is no derivable conflict.") - return { - "schema_version": "cad.requirements-normalization.v1", - "registry_version": REGISTRY_VERSION, - "rule_id": "full_circle_equal_spacing", - "rule_version": FULL_CIRCLE_EQUAL_SPACING_VERSION, - "task_id": task_id, - "draft_id": draft_id, - "source_ids": list(source_ids), - "original": {"count": count, "spacing_degrees": float(declared), "full_circle": True}, - "adopted": {"count": count, "spacing_degrees": adopted, "full_circle": True}, - "reason": description, - } diff --git a/backend/app/cad_agent/application/requirements.py b/backend/app/cad_agent/application/requirements.py new file mode 100644 index 00000000..3f6ab24d --- /dev/null +++ b/backend/app/cad_agent/application/requirements.py @@ -0,0 +1,320 @@ +"""One-pass requirements specification and server-owned contract artifacts.""" + +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json +from typing import Any + +from app.cad_agent.application.llm_contracts import ( + RequirementsAuthorOutput, + RequirementsClarification, + RequirementsSpec, + requirements_spec_schema, +) +from app.cad_agent.application.results import Accepted, Rejected, Waiting +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 + + +class RequirementsCommandHandler: + def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None: + self.repository = repository + self.artifacts = artifacts + self.registry = registry + 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: + """Retain release-evaluation metadata without changing production decisions.""" + 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) + if claims is None: + return None + return { + "evaluation_only": True, + "required_claims": deepcopy(claims), + "known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])), + } + + def spec_schema(self) -> dict[str, Any]: + return requirements_spec_schema(self.registry.expected_one_of_schema()) + + def submit_spec(self, task_id: str, output: RequirementsAuthorOutput, *, invocation_id: str) -> Accepted | Rejected | Waiting: + 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.DRAFTING_REQUIREMENTS: + return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements are not expected in the current workflow phase.")) + value = output.root + if isinstance(value, RequirementsClarification): + return self._record_clarification(task_id, state, value, invocation_id=invocation_id) + if not isinstance(value, RequirementsSpec): + return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements output is not a supported specification.")) + + field_errors: list[dict[str, str]] = [] + for requirement_index, requirement in enumerate(value.requirements): + for claim_index, claim in enumerate(requirement.acceptance_claims): + try: + errors = self.registry.validate_expected(claim.claim_kind, claim.expected) + except ValueError: + errors = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}] + field_errors.extend({ + "path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{error['path']}", + "message": error["message"], + } for error in errors) + if field_errors: + return Rejected(WorkflowError( + ErrorCode.REQUIREMENTS_SPEC_INVALID, + "Requirements specification 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_spec", state.working_head, value.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)) + image_observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} + warnings = [str(item) for item in image_observation.get("uncertainties") or () if str(item)] + requirements: list[dict[str, Any]] = [] + claim_position = 1 + for position, item in enumerate(value.requirements, 1): + claims: list[dict[str, Any]] = [] + for claim in item.acceptance_claims: + deterministic = self.registry.definition(claim.claim_kind).deterministic + claims.append({ + "claim_id": f"claim_{claim_position:03d}", + "claim_kind": claim.claim_kind, + "expected": claim.expected, + "verification_mode": "deterministic" if deterministic else "visual", + }) + claim_position += 1 + requirements.append({ + "requirement_id": f"req_{position:03d}", + "source_ids": source_ids, + "statement": item.statement, + "assumptions": list(item.assumptions), + "acceptance_claims": claims, + }) + spec_payload = { + "schema_version": "cad.requirements-spec.v1", + "summary": value.summary, + "assumptions": list(value.assumptions), + "requirements": [item.model_dump(mode="json") for item in value.requirements], + "image_observation_path": "documents/image-observation.json" if image_observation else "", + } + contract = { + "schema_version": "cad.requirements-contract.v3", + "task_id": task_id, + "summary": value.summary, + "assumptions": list(value.assumptions), + "requirements": requirements, + "verification_warnings": warnings, + } + contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + try: + spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec_payload) + 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_approved", + requirements_spec_path=spec_path, + requirements_contract_path=contract_path, + clarification_path="", + ) + result = Accepted({"phase": next_state.phase.value, "contract_path": contract_path}) + if not self._commit(next_state, [{ + "event": "requirements_contract_frozen", + "invocation_id": invocation_id, + "contract_hash": contract["contract_hash"], + "contract_path": contract_path, + "requirement_count": len(requirements), + "verification_warnings": warnings, + }], invocation, result): + return Rejected(self._stale()) + self.ensure_rendered_contract_views(task_id, next_state) + return result + + def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None: + if not state.requirements_contract_path: + return + contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) + if not isinstance(contract, dict): + raise RuntimeError("Committed requirements contract is unavailable") + self.artifacts.write_requirements_contract(task_id, contract) + self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract)) + target = self._completion_target_markdown(contract) + self.artifacts.write_text_once(task_id, "completion-target.md", target) + + 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", "", f"Status: {'completed with risks' if contract.get('verification_warnings') else 'verified'}", ""] + for requirement in contract.get("requirements") or (): + if not isinstance(requirement, dict): + continue + rows.append(f"## {requirement.get('statement')}") + for claim in requirement.get("acceptance_claims") or (): + if not isinstance(claim, dict): + continue + if claim.get("verification_mode") == "visual": + decision = next(visual, {}) + status = str(decision.get("status") or "unknown") + evidence = str(decision.get("evidence") or "") + else: + result = by_id.get(str(claim.get("claim_id") or ""), {}) + status = str(result.get("status") or "unknown") + evidence = json.dumps(result.get("evidence") or {}, ensure_ascii=False, sort_keys=True) + rows.append(f"- [{'x' if status == 'pass' else ' '}] {claim.get('claim_kind')}: {status}") + if evidence: + rows.append(f" - Evidence: {evidence}") + rows.append("") + warnings = [str(item) for item in contract.get("verification_warnings") or () if str(item)] + if warnings: + rows.extend(["## Verification Warnings", "", *[f"- {item}" for item in warnings], ""]) + return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n") + + def _record_clarification( + self, + task_id: str, + state: TaskState, + clarification: RequirementsClarification, + *, + invocation_id: str, + ) -> Waiting | Rejected: + evidence = self.artifacts.read_source_requirements(task_id) + observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} + evidence += "\n" + json.dumps(observation, ensure_ascii=False) + missing = [quote for quote in clarification.source_quotes if quote not in evidence] + if missing: + return Rejected(WorkflowError( + ErrorCode.REQUIREMENTS_SPEC_INVALID, + "Clarification quotes must be copied from the user request or image observation.", + field_errors=tuple({"path": "/source_quotes", "message": f"Unknown quote: {quote}"} for quote in missing), + )) + invocation = self.repository.begin_invocation( + task_id, + invocation_id, + self._key(task_id, "requirements_clarification", state.working_head, clarification.model_dump(mode="json")), + ) + payload = {"schema_version": "cad.requirements-clarification.v1", **clarification.model_dump(mode="json")} + try: + path = self.artifacts.write_json_once(task_id, f"documents/requirements-clarification-{sha256(clarification.question.encode()).hexdigest()[:12]}.json", payload) + except OSError as error: + return self._park_for_storage_retry(state, str(error)) + next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, clarification_path=path) + result = Waiting(WorkflowError( + ErrorCode.WAITING_FOR_USER, + clarification.question, + details={"questions": [clarification.question], "source_quotes": list(clarification.source_quotes)}, + )) + if not self._commit(next_state, [{ + "event": "requirements_waiting_for_user", + "invocation_id": invocation_id, + "review_path": path, + "message": clarification.question, + "questions": [clarification.question], + "source_quotes": list(clarification.source_quotes), + }], invocation, result): + return Rejected(self._stale()) + return result + + def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None: + invocation = self.repository.get_invocation(task_id, invocation_id) + if invocation is None or invocation.status != "finished" or invocation.result is None: + return None + return self._restore(invocation.result) + + @staticmethod + def _restore(payload: dict[str, Any]) -> Accepted | Waiting: + if payload.get("result_type") == "waiting": + error = payload.get("error") if isinstance(payload.get("error"), dict) else {} + return Waiting(WorkflowError( + ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)), + str(error.get("message") or "Requirements need a user decision."), + tuple(error.get("field_errors") or ()), + bool(error.get("retryable")), + dict(error.get("details") or {}), + )) + 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 | Waiting) -> bool: + payload = {"result_type": "waiting", "error": result.error.payload()} if isinstance(result, Waiting) else {"result_type": "accepted", "payload": result.payload} + return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_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("utf-8")).hexdigest() + + @staticmethod + def _requirements_markdown(contract: dict[str, Any]) -> str: + rows = ["# Requirements", "", str(contract.get("summary") or ""), ""] + assumptions = [str(item) for item in contract.get("assumptions") or () if str(item)] + if assumptions: + rows.extend(["## Assumptions", "", *[f"- {item}" for item in assumptions], ""]) + rows.extend(["## Requirements", ""]) + for item in contract.get("requirements") or (): + if not isinstance(item, dict): + continue + rows.append(f"- {item.get('statement')}") + rows.extend(f" - Assumption: {value}" for value in item.get("assumptions") or ()) + return "\n".join(rows).rstrip() + "\n" + + @staticmethod + def _completion_target_markdown(contract: dict[str, Any]) -> str: + rows = ["# Completion Target", ""] + for requirement in contract.get("requirements") or (): + if not isinstance(requirement, dict): + continue + rows.append(f"## {requirement.get('statement')}") + for claim in requirement.get("acceptance_claims") or (): + if isinstance(claim, dict): + rows.append(f"- [ ] {claim.get('claim_kind')}: {json.dumps(claim.get('expected') or {}, ensure_ascii=False, sort_keys=True)}") + rows.append("") + return "\n".join(rows).rstrip() + "\n" + + @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)) diff --git a/backend/app/cad_agent/application/requirements_review.py b/backend/app/cad_agent/application/requirements_review.py deleted file mode 100644 index 40a3527b..00000000 --- a/backend/app/cad_agent/application/requirements_review.py +++ /dev/null @@ -1,665 +0,0 @@ -"""Requirements drafting/review handlers; Markdown is rendered, never parsed.""" - -from __future__ import annotations - -from copy import deepcopy -from hashlib import sha256 -import json -from typing import Any - -from app.cad_agent.application.llm_contracts import ( - RequirementsDraftBatch, - RequirementsPatchBatch, - RequirementsReview, - requirements_draft_schema, - requirements_patch_schema, - requirements_review_schema, -) -from app.cad_agent.application.normalization import NormalizationError, resolve_normalization -from app.cad_agent.application.results import Accepted, Rejected, Waiting -from app.cad_agent.domain.claim_matching import contains_expected -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 - - -class RequirementsCommandHandler: - def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None: - self.repository = repository - self.artifacts = artifacts - self.registry = registry - # This intentionally has no production configuration path. The live - # evaluator registers its fixture oracle per task before authoring. - 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: - """Register an external contract oracle for one live-evaluation task. - - The source requirement remains evidence for normal production work; - this guarded fixture data exists only to keep an evaluation from - accepting a reviewer false positive before CAD execution starts. - """ - normalized: list[dict[str, Any]] = [] - for item in required_claims: - claim_kind = item.get("claim_kind") if isinstance(item, dict) else None - expected = item.get("expected") if isinstance(item, dict) else None - if ( - not isinstance(claim_kind, str) - or claim_kind not in self.registry.claim_kinds - or not isinstance(expected, dict) - ): - raise ValueError("Evaluation contract oracle claims require claim_kind and expected object.") - # The frozen contract is production-valid and complete. Fixture - # expectations are intentionally partial so they can compare - # provider-chosen optional fields such as tolerances. - normalized.append({"claim_kind": claim_kind, "expected": deepcopy(expected)}) - if not normalized: - raise ValueError("Evaluation contract oracle requires at least one claim.") - self._evaluation_contract_oracles[task_id] = normalized - gaps: list[dict[str, str]] = [] - for gap in validation_capability_gaps or (): - gap_id = gap.get("id") if isinstance(gap, dict) else None - description = gap.get("description") if isinstance(gap, dict) else None - if not isinstance(gap_id, str) or not gap_id or not isinstance(description, str) or not description: - raise ValueError("Evaluation capability gaps require id and description.") - gaps.append({"id": gap_id, "description": description}) - self._evaluation_capability_gaps[task_id] = gaps - - def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None: - """Expose fixture-only known verifier gaps to the live reviewer.""" - gaps = self._evaluation_capability_gaps.get(task_id) - if gaps is None: - return None - return {"evaluation_only": True, "known_validation_capability_gaps": deepcopy(gaps)} - - def draft_schema(self, task_id: str) -> dict[str, Any]: - return requirements_draft_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id))) - - def patch_schema(self, task_id: str) -> dict[str, Any]: - state = self.repository.get_state(task_id) - draft = self._draft(state) - ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")] - return requirements_patch_schema(self.registry.expected_one_of_schema(), list(self.artifacts.read_source_index(task_id)), ids) - - def review_schema(self, task_id: str) -> dict[str, Any]: - state = self.repository.get_state(task_id) - draft = self._draft(state) - draft_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")] - source_ids = list(self.artifacts.read_source_index(task_id)) - return requirements_review_schema(source_ids, draft_ids) - - def submit_draft(self, task_id: str, batch: RequirementsDraftBatch, *, 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: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task does not exist.")) - if state.phase != TaskPhase.DRAFTING_REQUIREMENTS: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be drafted in the current workflow phase.")) - draft = self._draft(state) - source_index = self.artifacts.read_source_index(task_id) - current = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)] - errors = self._validate_items(batch.items, source_index, current) - if errors: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements draft batch failed canonical validation.", field_errors=tuple(errors))) - invocation = self.repository.begin_invocation( - task_id, - invocation_id, - self._key(task_id, "requirements_draft", state.working_head, batch.model_dump(mode="json")), - ) - if invocation.status == "finished" and invocation.result is not None: - return self._restore(invocation.result) - start = len(current) + 1 - for position, item in enumerate(batch.items, start): - current.append({"draft_id": f"draft_{position:03d}", **item.model_dump(mode="json")}) - payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": current} - try: - artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id) - except OSError as error: - return self._park_for_storage_retry( - state, - event="requirements_draft_storage_failure", - message=str(error), - ) - next_state = transition(state, "draft_updated", requirements_draft_path=artifact_path) - result = Accepted({"draft_revision": payload["revision"], "draft_ids": [item["draft_id"] for item in current[-len(batch.items):]]}) - if not self._commit(next_state, [{"event": "requirements_draft_updated", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(current)}], invocation, result): - return Rejected(self._stale()) - return result - - def patch_draft(self, task_id: str, batch: RequirementsPatchBatch, *, 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.DRAFTING_REQUIREMENTS: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements patches are not allowed in the current workflow phase.")) - draft = self._draft(state) - items = [deepcopy(item) for item in draft.get("items") or () if isinstance(item, dict)] - by_id = {str(item.get("draft_id") or ""): item for item in items} - source_index = self.artifacts.read_source_index(task_id) - errors: list[dict[str, str]] = [] - seen: set[str] = set() - for index, patch in enumerate(batch.patches): - if patch.target_draft_id in seen: - errors.append({"path": f"/patches/{index}/target_draft_id", "message": "A draft item may be patched only once per batch."}) - seen.add(patch.target_draft_id) - if patch.target_draft_id not in by_id: - errors.append({"path": f"/patches/{index}/target_draft_id", "message": "Unknown current draft ID."}) - if patch.item: - errors.extend(self._validate_items([patch.item], source_index, [item for item in items if item.get("draft_id") != patch.target_draft_id], prefix=f"/patches/{index}/item")) - if errors: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements patch batch failed canonical validation.", field_errors=tuple(errors))) - invocation = self.repository.begin_invocation( - task_id, - invocation_id, - self._key(task_id, "requirements_patch", state.working_head, batch.model_dump(mode="json")), - ) - if invocation.status == "finished" and invocation.result is not None: - return self._restore(invocation.result) - remaining: list[dict[str, Any]] = [] - patches = {patch.target_draft_id: patch for patch in batch.patches} - for item in items: - patch = patches.get(str(item["draft_id"])) - if patch is None: - remaining.append(item) - elif patch.op == "replace" and patch.item is not None: - remaining.append({"draft_id": item["draft_id"], **patch.item.model_dump(mode="json")}) - payload = {"schema_version": "cad.requirements-draft.v1", "revision": int(draft.get("revision") or 0) + 1, "items": remaining} - try: - artifact_path = self.artifacts.write_requirements_draft(task_id, payload, invocation_id=invocation_id) - except OSError as error: - return self._park_for_storage_retry( - state, - event="requirements_patch_storage_failure", - message=str(error), - ) - # A successful patch creates a new draft revision. The prior review - # describes the old revision and must not keep the author trapped in - # patch-only mode; it will be replaced after explicit re-finalization. - next_state = transition( - state, - "draft_updated", - requirements_draft_path=artifact_path, - requirements_review_path="", - ) - result = Accepted({"draft_revision": payload["revision"], "item_count": len(remaining)}) - if not self._commit(next_state, [{"event": "requirements_draft_patched", "invocation_id": invocation_id, "draft_revision": payload["revision"], "draft_path": artifact_path, "item_count": len(remaining)}], invocation, result): - return Rejected(self._stale()) - return result - - def finalize_draft(self, task_id: str, *, 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.DRAFTING_REQUIREMENTS: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements cannot be finalized in the current workflow phase.")) - draft = self._draft(state) - items = [item for item in draft.get("items") or () if isinstance(item, dict)] - source_index = self.artifacts.read_source_index(task_id) - covered = {str(source) for item in items for source in item.get("source_ids") or ()} - missing = sorted(set(source_index) - covered) - if not items or missing: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "REQUIREMENTS_COVERAGE_INCOMPLETE", details={"missing_source_ids": missing})) - invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_finalize", state.working_head, {})) - if invocation.status == "finished" and invocation.result is not None: - return self._restore(invocation.result) - next_state = transition(state, "requirements_finalized") - result = Accepted({"phase": next_state.phase.value, "draft_revision": draft.get("revision", 0)}) - if not self._commit(next_state, [{"event": "requirements_review_requested", "invocation_id": invocation_id, "draft_revision": draft.get("revision", 0)}], invocation, result): - return Rejected(self._stale()) - return result - - def record_review(self, task_id: str, review: RequirementsReview, *, invocation_id: str) -> Accepted | Rejected | Waiting: - 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.REVIEWING_REQUIREMENTS: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements review is not expected in the current workflow phase.")) - draft = self._draft(state) - items = [item for item in draft.get("items") or () if isinstance(item, dict)] - source_ids = set(self.artifacts.read_source_index(task_id)) - draft_ids = {str(item.get("draft_id") or "") for item in items} - errors = self._validate_review(review, source_ids, items, draft_ids) - if errors: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Requirements review failed canonical validation.", field_errors=tuple(errors))) - invocation = self.repository.begin_invocation( - task_id, - invocation_id, - self._key(task_id, "requirements_review", state.working_head, review.model_dump(mode="json")), - ) - if invocation.status == "finished" and invocation.result is not None: - return self._restore(invocation.result) - coverage = self._derive_coverage(source_ids, items) - normalizations: list[dict[str, Any]] = [] - normalization_errors: list[dict[str, str]] = [] - for index, finding in enumerate(review.findings): - if finding.finding_type != "derivable_conflict" or finding.normalization is None: - continue - try: - normalizations.append(resolve_normalization( - task_id=task_id, - draft_id=finding.draft_id, - source_ids=list(finding.source_ids), - description=finding.description, - value=finding.normalization.model_dump(mode="json"), - )) - except NormalizationError as error: - normalization_errors.append({"path": f"/findings/{index}/normalization", "message": str(error)}) - if normalization_errors: - return Rejected(WorkflowError( - ErrorCode.AUTHOR_FORMAT_INVALID, - "Requirements review supplied an invalid deterministic normalization.", - field_errors=tuple(normalization_errors), - )) - warnings = [ - finding.description - for finding in review.findings - if finding.finding_type == "verification_gap" - ] - author_findings = [ - finding for finding in review.findings - if finding.finding_type in {"missing_source_semantics", "claim_mismatch"} - or ( - finding.finding_type == "verification_gap" - and not self._draft_has_visual_claim(items, finding.draft_id) - ) - ] - ambiguous = [finding for finding in review.findings if finding.finding_type == "ambiguous_conflict"] - decision = "waiting_for_user" if ambiguous else "revise" if author_findings else "freeze" - review_payload = { - "schema_version": "cad.requirements-review.v2", - "findings": [finding.model_dump(mode="json") for finding in review.findings], - "coverage": coverage, - "decision": decision, - "verification_warnings": warnings, - "applied_normalizations": normalizations, - } - try: - review_path = self.artifacts.write_requirements_review(task_id, review_payload, invocation_id=invocation_id) - except OSError as error: - return self._park_for_storage_retry( - state, - event="requirements_review_storage_failure", - message=str(error), - ) - if ambiguous: - next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, requirements_review_path=review_path) - questions = [str(finding.question) for finding in ambiguous if finding.question] - message = "Requirements contain an ambiguity that cannot be resolved deterministically." - ambiguous_findings = [finding.model_dump(mode="json") for finding in ambiguous] - result = Waiting(WorkflowError( - ErrorCode.WAITING_FOR_USER, - message, - details={"questions": questions, "findings": ambiguous_findings}, - )) - if not self._commit(next_state, [{ - "event": "requirements_waiting_for_user", - "invocation_id": invocation_id, - "review_path": review_path, - "message": message, - "questions": questions, - "findings": ambiguous_findings, - }], invocation, result): - return Rejected(self._stale()) - return result - if author_findings: - next_state = transition(state, "requirements_revise", requirements_review_path=review_path) - result = Accepted({"phase": next_state.phase.value, "review": review_payload}) - if not self._commit(next_state, [{"event": "requirements_review_revise", "invocation_id": invocation_id, "review_path": review_path}], invocation, result): - return Rejected(self._stale()) - return result - if normalizations: - normalization_digest = sha256( - json.dumps(normalizations, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") - ).hexdigest()[:12] - try: - normalization_path = self.artifacts.write_json_once( - task_id, - f"documents/requirements-normalizations-{normalization_digest}.json", - { - "schema_version": "cad.requirements-normalizations.v1", - "task_id": task_id, - "items": normalizations, - }, - ) - except OSError as error: - return self._park_for_storage_retry( - state, - event="requirements_normalization_storage_failure", - message=str(error), - ) - else: - normalization_path = "" - contract = self._freeze_contract( - task_id, - items, - verification_warnings=warnings, - applied_normalizations=normalizations, - ) - missing_claims = self._missing_evaluation_oracle_claims(task_id, contract) - if missing_claims: - oracle_payload = self._evaluation_oracle_failure( - task_id, - draft_revision=int(draft.get("revision") or 0), - reviewer_review_path=review_path, - draft_ids=sorted(draft_ids), - missing_claims=missing_claims, - ) - oracle_digest = sha256( - json.dumps(oracle_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") - ).hexdigest()[:12] - try: - oracle_path = self.artifacts.write_json_once( - task_id, - f"documents/evaluation-contract-oracle-{oracle_digest}.json", - oracle_payload, - ) - except OSError as error: - return self._park_for_storage_retry( - state, - event="evaluation_contract_oracle_storage_failure", - message=str(error), - ) - next_state = transition(state, "requirements_revise", requirements_review_path=oracle_path) - result = Accepted({ - "phase": next_state.phase.value, - "review": oracle_payload, - "reviewer_review_path": review_path, - "evaluation_oracle": {"missing_claims": missing_claims}, - }) - if not self._commit(next_state, [{ - "event": "requirements_evaluation_oracle_revise", - "invocation_id": invocation_id, - "review_path": review_path, - "oracle_path": oracle_path, - "missing_claims": missing_claims, - }], invocation, result): - return Rejected(self._stale()) - return result - try: - 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, - event="requirements_contract_storage_failure", - message=str(error), - ) - next_state = transition( - state, - "requirements_approved", - requirements_review_path=review_path, - requirements_contract_path=contract_path, - ) - result = Accepted({ - "phase": next_state.phase.value, - "contract_hash": contract["contract_hash"], - "verification_warnings": warnings, - "applied_normalizations": normalizations, - }) - events = [] - if normalizations: - events.append({ - "event": "requirements_normalized", - "normalization_path": normalization_path, - "applied_normalizations": normalizations, - }) - events.append({"event": "requirements_contract_frozen", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "contract_path": contract_path, "review_path": review_path, "requirement_count": len(contract["requirements"]), "verification_warnings": warnings, "applied_normalizations": normalizations}) - if not self._commit(next_state, events, invocation, result): - return Rejected(self._stale()) - return result - - def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None: - """Rebuild read-only contract views from the committed immutable path. - - The SQLite transition is authoritative. Rendering convenience files - after its commit must never reclassify a frozen contract as an - internal authoring failure, so this idempotent operation is replayed - by the workflow until every view exists. - """ - if not state.requirements_contract_path: - return - contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - if not isinstance(contract, dict): - raise RuntimeError("Committed requirements contract is unavailable") - self.artifacts.write_requirements_contract(task_id, contract) - self.artifacts.write_json_once(task_id, "requirements-index.json", { - "schema_version": "cad.requirements-index.v1", - "requirements": [ - { - "requirement_id": item["requirement_id"], - "claim_ids": [claim["claim_id"] for claim in item["acceptance_claims"]], - } - for item in contract.get("requirements") or () - if isinstance(item, dict) - ], - }) - self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract)) - self.artifacts.write_text_once(task_id, "completion.md", self._completion_markdown(contract)) - - def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None: - invocation = self.repository.get_invocation(task_id, invocation_id) - if invocation is None or invocation.status != "finished" or invocation.result is None: - return None - return self._restore(invocation.result) - - def _finish(self, invocation_id: str, result: Accepted | Waiting) -> None: - if isinstance(result, Waiting): - payload = {"result_type": "waiting", "error": result.error.payload()} - else: - payload = {"result_type": "accepted", "payload": result.payload} - self.repository.finish_invocation(invocation_id, payload) - - def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool: - if isinstance(result, Waiting): - payload = {"result_type": "waiting", "error": result.error.payload()} - else: - payload = {"result_type": "accepted", "payload": result.payload} - return self.repository.compare_and_swap( - state, - events=events, - invocation_id=invocation.invocation_id, - invocation_result=payload, - ) - - @staticmethod - def _restore(payload: dict[str, Any]) -> Accepted | Waiting: - if payload.get("result_type") == "waiting": - error = payload.get("error") if isinstance(payload.get("error"), dict) else {} - return Waiting(WorkflowError( - ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)), - str(error.get("message") or "Requirements need a user decision."), - tuple(error.get("field_errors") or ()), - bool(error.get("retryable")), - dict(error.get("details") or {}), - )) - value = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload - return Accepted(value) - - def _draft(self, state: TaskState | None) -> dict[str, Any]: - return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path) if state is not None else {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []} - - @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("utf-8")).hexdigest() - - def _validate_items(self, inputs: list[Any], source_index: dict[str, str], existing: list[dict[str, Any]], *, prefix: str = "/items") -> list[dict[str, str]]: - errors: list[dict[str, str]] = [] - seen = {(tuple(item.get("source_ids") or ()), str(item.get("statement") or "").casefold()) for item in existing} - for index, item in enumerate(inputs): - path = f"{prefix}/{index}" - if not set(item.source_ids).issubset(source_index): - errors.append({"path": f"{path}/source_ids", "message": "source_ids must come from the current source index."}) - signature = (tuple(item.source_ids), item.statement.casefold()) - if signature in seen: - errors.append({"path": f"{path}/statement", "message": "Duplicate requirement item."}) - seen.add(signature) - for claim_index, claim in enumerate(item.acceptance_claims): - try: - claim_errors = self.registry.validate_expected(claim.claim_kind, claim.expected) - except ValueError: - errors.append({"path": f"{path}/acceptance_claims/{claim_index}/claim_kind", "message": "VERIFIER_UNAVAILABLE"}) - continue - errors.extend({"path": f"{path}/acceptance_claims/{claim_index}/expected{error['path']}", "message": error["message"]} for error in claim_errors) - return errors - - @staticmethod - def _validate_review( - review: RequirementsReview, - source_ids: set[str], - items: list[dict[str, Any]], - draft_ids: set[str], - ) -> list[dict[str, str]]: - errors: list[dict[str, str]] = [] - by_draft = {str(item.get("draft_id") or ""): item for item in items} - for index, finding in enumerate(review.findings): - if finding.draft_id not in draft_ids: - errors.append({"path": f"/findings/{index}/draft_id", "message": "Finding must reference a current draft item."}) - continue - if not set(finding.source_ids).issubset(source_ids): - errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must come from the current source index."}) - continue - cited = set(by_draft[finding.draft_id].get("source_ids") or ()) - if not set(finding.source_ids).issubset(cited): - errors.append({"path": f"/findings/{index}/source_ids", "message": "Finding source_ids must be cited by its draft item."}) - return errors - - @staticmethod - def _derive_coverage(source_ids: set[str], items: list[dict[str, Any]]) -> list[dict[str, Any]]: - coverage = {source_id: [] for source_id in sorted(source_ids)} - for position, item in enumerate(items, 1): - requirement_id = f"req_{position:03d}" - for source_id in item.get("source_ids") or (): - if source_id in coverage: - coverage[source_id].append(requirement_id) - return [{"source_id": source_id, "requirement_ids": requirement_ids} for source_id, requirement_ids in coverage.items()] - - @staticmethod - def _draft_has_visual_claim(items: list[dict[str, Any]], draft_id: str) -> bool: - return any( - claim.get("claim_kind") == "visual" - for item in items - if item.get("draft_id") == draft_id - for claim in item.get("acceptance_claims") or () - if isinstance(claim, dict) - ) - - def _freeze_contract( - self, - task_id: str, - items: list[dict[str, Any]], - *, - verification_warnings: list[str], - applied_normalizations: list[dict[str, Any]], - ) -> dict[str, Any]: - requirements: list[dict[str, Any]] = [] - claim_position = 1 - for position, item in enumerate(items, 1): - claims: list[dict[str, Any]] = [] - for claim in item.get("acceptance_claims") or (): - deterministic = self.registry.definition(str(claim["claim_kind"])).deterministic - claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim["claim_kind"], "expected": claim["expected"], "verification_mode": "deterministic" if deterministic else "visual"}) - claim_position += 1 - requirements.append({"requirement_id": f"req_{position:03d}", "draft_id": item["draft_id"], "source_ids": item["source_ids"], "statement": item["statement"], "assumptions": item["assumptions"], "acceptance_claims": claims}) - payload = { - "schema_version": "cad.requirements-contract.v2", - "task_id": task_id, - "requirements": requirements, - "verification_warnings": list(verification_warnings), - "applied_normalizations": deepcopy(applied_normalizations), - } - payload["contract_hash"] = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() - return payload - - def _missing_evaluation_oracle_claims(self, task_id: str, contract: dict[str, Any]) -> list[dict[str, Any]]: - required = self._evaluation_contract_oracles.get(task_id) - if not required: - return [] - actual = [ - {"claim_kind": str(claim.get("claim_kind") or ""), "expected": claim.get("expected")} - 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("expected"), dict) - ] - return [ - deepcopy(claim) - for claim in required - if not any( - actual_claim["claim_kind"] == claim["claim_kind"] - and contains_expected(actual_claim["expected"], claim["expected"]) - for actual_claim in actual - ) - ] - - @staticmethod - def _evaluation_oracle_failure( - task_id: str, - *, - draft_revision: int, - reviewer_review_path: str, - draft_ids: list[str], - missing_claims: list[dict[str, Any]], - ) -> dict[str, Any]: - return { - "schema_version": "cad.requirements-evaluation-oracle.v1", - "task_id": task_id, - "evaluation_only": True, - "decision": "revise", - "draft_revision": draft_revision, - "reviewer_decision": "freeze", - "reviewer_review_path": reviewer_review_path, - "findings": [ - { - "draft_id": draft_id, - "source_ids": [], - "finding_type": "missing_source_semantics", - "description": "The external live-evaluation contract oracle found missing executable acceptance claims.", - } - for draft_id in draft_ids - ], - "missing_claims": missing_claims, - } - - @staticmethod - def _requirements_markdown(contract: dict[str, Any]) -> str: - rows = ["# Requirements", ""] - for item in contract["requirements"]: - rows.append(f"- {item['requirement_id']}: {item['statement']}") - for assumption in item["assumptions"]: - rows.append(f" - Assumption: {assumption}") - return "\n".join(rows) + "\n" - - @staticmethod - def _completion_markdown(contract: dict[str, Any]) -> str: - return "# Completion\n\n" + "\n".join(f"- [ ] {item['requirement_id']}: {item['statement']}" for item in contract["requirements"]) + "\n" - - @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, *, event: str, message: str) -> Rejected: - waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE) - if not self.repository.compare_and_swap(waiting, events=[{ - "event": event, - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - }]): - return Rejected(self._stale()) - return Rejected(WorkflowError( - ErrorCode.STORAGE_FAILURE, - "Requirements artifact storage is temporarily unavailable; the task can be resumed.", - retryable=True, - )) diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index 1394338a..3b64001f 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -17,19 +17,20 @@ from pydantic import BaseModel from app.cad_agent.application.action_handlers import ActionCommandHandler from app.cad_agent.application.llm_contracts import ( - CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, NextAction, - OperationContractRequest, RequirementsDraftBatch, RequirementsPatchBatch, - RequirementsReview, RollbackCheckpoint, TopologyRequest, - candidate_review_schema, canonical_json_object, canonical_validate, canonical_validate_schema, - final_review_schema, geometry_conclusion_schema, next_action_schema, - operation_contract_request_schema, rollback_checkpoint_schema, topology_request_schema, + CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, NextAction, + RequirementsAuthorOutput, RollbackCheckpoint, StatelessCandidateReview, + StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint, + StatelessTopologyRequest, TopologyRequest, + canonical_json_object, canonical_validate, canonical_validate_schema, + stateless_final_review_schema, + stateless_next_action_schema, stateless_rollback_checkpoint_schema, raw_arguments_hash, validate_one_tool_call, ) -from app.cad_agent.application.requirements_review import RequirementsCommandHandler +from app.cad_agent.application.requirements import RequirementsCommandHandler from app.cad_agent.application.results import Accepted, Rejected, Waiting from app.cad_agent.domain.errors import ErrorCode, WorkflowError from app.cad_agent.domain.operation_contract import fragment_schema -from app.cad_agent.domain.state import TaskPhase, TaskState, reject_stale_head, retry_resume_event, transition +from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository @@ -46,7 +47,6 @@ class ModelIdentity: class WorkflowConfig: max_turns: int format_error_limit: int - requirements_review_limit: int = 3 author_fallbacks: tuple[ModelIdentity, ...] = () max_author_turns: int | None = None max_reviewer_turns: int | None = None @@ -129,11 +129,12 @@ class WorkflowCoordinator: request: str, *, source_blocks: list[dict[str, Any]] | None = None, + image_inputs: list[dict[str, str]] | None = None, ) -> TaskState: # The immutable source artifact is safe to create before SQLite state: # an interrupted creation leaves only an unreferenced directory, never # a runnable task without its source index. - self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks) + self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) return self.repository.create_task(task_id, request) def resume(self, task_id: str) -> bool: @@ -158,15 +159,8 @@ class WorkflowCoordinator: state = self.repository.get_state(task_id) if state is None or state.phase != TaskPhase.WAITING_FOR_USER: return False - review = self._requirements_review(task_id, state) - is_requirements_pause = isinstance(review, dict) and ( - review.get("decision") == "waiting_for_user" - and any( - item.get("finding_type") == "ambiguous_conflict" and item.get("question") - for item in review.get("findings") or () if isinstance(item, dict) - ) - ) - if not is_requirements_pause: + clarification_request = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None + if not isinstance(clarification_request, dict) or not str(clarification_request.get("question") or "").strip(): return False text = clarification.strip() if not text: @@ -182,7 +176,7 @@ class WorkflowCoordinator: }) except OSError: return False - resumed = transition(state, "requirements_clarified") + resumed = transition(state, "requirements_clarified", clarification_path="") return self.repository.compare_and_swap(resumed, events=[{ "event": "user_clarification_received", "message_id": message_id, @@ -271,9 +265,42 @@ class WorkflowCoordinator: return continue if state.phase == TaskPhase.DRAFTING_REQUIREMENTS: - draft_schema = self.requirements.draft_schema(task_id) - patch_schema = self.requirements.patch_schema(task_id) - tools = self._requirements_author_tools(task_id, draft_schema, patch_schema) + image_paths = self.artifacts.source_image_paths(task_id) + if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None: + terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") + if terminal: + yield terminal + return + call_budget.record_attempt("reviewer") + observation = await self._observe_images(task_id, reviewer, image_paths) + if isinstance(observation, WorkflowError): + if observation.code == ErrorCode.AUTHOR_FORMAT_INVALID: + observation = WorkflowError( + ErrorCode.REVIEW_SERVICE_UNAVAILABLE, + "Image observation did not return the required structured format.", + field_errors=observation.field_errors, + retryable=True, + ) + yield self._service_failure(task_id, state, observation) + return + try: + self.artifacts.write_json_once(task_id, "documents/image-observation.json", { + "schema_version": "cad.image-observation.v3", + **observation.model_dump(mode="json"), + }) + except OSError as error: + yield self._storage_failure(task_id, str(error)) + return + observed_state = transition(state, "image_observed") + self.repository.compare_and_swap(observed_state, events=[{ + "event": "image_observation_ready", + "path": "documents/image-observation.json", + "image_count": len(image_paths), + }]) + yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"} + continue + spec_schema = self.requirements.spec_schema() + tools = [self._tool("submit_requirements_spec", spec_schema)] terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") if terminal: yield terminal @@ -294,98 +321,33 @@ class WorkflowCoordinator: return continue name, raw, usage = result - model = RequirementsDraftBatch if name == "submit_requirements_draft_batch" else RequirementsPatchBatch if name == "patch_requirements_draft" else EmptyCommand - validation = canonical_validate(raw, model) - dynamic_error = canonical_validate_schema(raw, draft_schema if name == "submit_requirements_draft_batch" else patch_schema) if not isinstance(validation, WorkflowError) and name != "finalize_requirements_draft" else None + validation = canonical_validate(raw, RequirementsAuthorOutput) + dynamic_error = canonical_validate_schema(raw, spec_schema) if not isinstance(validation, WorkflowError) else None if dynamic_error is not None: validation = dynamic_error if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) + terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback) yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) if terminal: yield terminal return continue invocation_id = self._invocation_id(task_id) - command = self.requirements.submit_draft(task_id, validation, invocation_id=invocation_id) if name == "submit_requirements_draft_batch" else self.requirements.patch_draft(task_id, validation, invocation_id=invocation_id) if name == "patch_requirements_draft" else self.requirements.finalize_draft(task_id, invocation_id=invocation_id) + command = self.requirements.submit_spec(task_id, validation, invocation_id=invocation_id) if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) + terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback) yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) if terminal: yield terminal return continue - yield "tool_call", self._event(task_id, name, self._result_payload(command), "success", usage) + yield "requirements_ready", self._event(task_id, name, self._result_payload(command), "waiting" if isinstance(command, Waiting) else "success", usage) feedback = [] continue - if state.phase == TaskPhase.REVIEWING_REQUIREMENTS: - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - command = await self._review_requirements(task_id, reviewer, feedback) - if isinstance(command, WorkflowError): - if command.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "review_requirements", command, format_errors, feedback, actor="reviewer") - yield "requirements_review", {"taskId": task_id, "status": "error", "result": command.payload()} - if terminal: - yield terminal - return - continue - terminal = self._service_failure(task_id, state, command) - yield terminal - return - review_result = self.requirements.record_review(task_id, command, invocation_id=self._invocation_id(task_id)) - if isinstance(review_result, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, "review_requirements", review_result.error, format_errors, feedback, actor="reviewer") - yield "requirements_review", {"taskId": task_id, "status": "error", "result": review_result.error.payload()} - if terminal: - yield terminal - return - continue - yield "requirements_review", {"taskId": task_id, "status": "success", "result": self._result_payload(review_result)} - if isinstance(review_result, Accepted) and review_result.payload.get("phase") == TaskPhase.DRAFTING_REQUIREMENTS.value: - draft_state = self.repository.get_state(task_id) - revision = int(self._requirements_draft(draft_state).get("revision") or 0) - # The initial draft is reviewed before any revision. - # ``requirements_review_limit`` bounds subsequent - # author corrections, so permit exactly that many - # patches and park only after they are exhausted. - if revision > self.config.requirements_review_limit: - if draft_state is not None: - failed = transition(draft_state, "failed", error=ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED) - review = self._requirements_review(task_id, draft_state) or {} - findings = [ - item for item in review.get("findings") or () - if isinstance(item, dict) - ] - self.repository.compare_and_swap(failed, events=[{ - "event": "requirements_review_limit_reached", - "code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value, - "draft_revision": revision, - "review_path": draft_state.requirements_review_path, - "findings": findings, - }]) - yield "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value, - "message": "Requirements review did not converge within the configured author-revision limit.", - "issues": [str(item.get("description") or "") for item in findings if item.get("description")], - "blockerType": "requirements_review_not_converged", - "userActionRequired": False, - } - return - continue if state.phase == TaskPhase.AWAITING_ACTION: contract = self._requirements_contract(task_id, state) or {} requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")] - action_schema = next_action_schema( - state.working_head, - requirement_ids, - list(self.actions.available_atomic_ids(task_id, state)), - ) + action_schema = stateless_next_action_schema(list(self.actions.available_atomic_ids(task_id, state))) tools = self._recovery_tools(task_id, state) if not tools: if self._can_complete(task_id, state): @@ -423,11 +385,7 @@ class WorkflowCoordinator: continue command = self.actions.complete_task(task_id, invocation_id=self._invocation_id(task_id)) elif name == "record_geometry_conclusion": - diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state))) - validation = canonical_validate(raw, GeometryConclusion) - dynamic_error = canonical_validate_schema(raw, diagnostic_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error + validation = canonical_validate(raw, StatelessGeometryConclusion) if isinstance(validation, WorkflowError): terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) @@ -435,10 +393,17 @@ class WorkflowCoordinator: yield terminal return continue + validation = GeometryConclusion( + working_head=state.working_head, + evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)), + root_cause=validation.root_cause, + decision=validation.decision, + corrective_intent=validation.corrective_intent, + ) command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id)) elif name == "rollback_checkpoint": - rollback_schema = rollback_checkpoint_schema(state.working_head, list(self.actions.checkpoint_tokens(task_id, state))) - validation = canonical_validate(raw, RollbackCheckpoint) + rollback_schema = stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))) + validation = canonical_validate(raw, StatelessRollbackCheckpoint) dynamic_error = canonical_validate_schema(raw, rollback_schema) if not isinstance(validation, WorkflowError) else None if dynamic_error is not None: validation = dynamic_error @@ -449,9 +414,10 @@ class WorkflowCoordinator: yield terminal return continue + validation = RollbackCheckpoint(working_head=state.working_head, checkpoint_token=validation.checkpoint_token, reason=validation.reason) command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id)) else: - validation = canonical_validate(raw, NextAction) + validation = canonical_validate(raw, StatelessNextAction) dynamic_error = canonical_validate_schema(raw, action_schema) if not isinstance(validation, WorkflowError) else None if dynamic_error is not None: validation = dynamic_error @@ -462,6 +428,13 @@ class WorkflowCoordinator: yield terminal return continue + validation = NextAction( + working_head=state.working_head, + intent=validation.intent, + requirement_ids=requirement_ids, + atomic_id=validation.operation, + expected_change=validation.expected_change, + ) command = self.actions.propose_next_action(task_id, validation, invocation_id=self._invocation_id(task_id)) if isinstance(command, Rejected): terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) @@ -523,10 +496,7 @@ class WorkflowCoordinator: feedback = [] continue if name == "inspect_topology": - validation = canonical_validate(raw, TopologyRequest) - dynamic_error = canonical_validate_schema(raw, topology_request_schema(state.working_head)) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error + validation = canonical_validate(raw, StatelessTopologyRequest) if isinstance(validation, WorkflowError): terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) @@ -534,36 +504,9 @@ class WorkflowCoordinator: yield terminal return continue - stale = reject_stale_head(state, validation.working_head) - payload = stale.payload() if stale else self._topology_payload(task_id, state, validation.kind, validation.limit) - if stale is None: - observed.add("topology") - yield "tool_call", self._event(task_id, name, payload, "error" if stale else "success", usage) - feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:] - continue - if name == "get_cdsl_operation_contract": - validation = canonical_validate(raw, OperationContractRequest) - action = state.pending_action - dynamic_error = canonical_validate_schema(raw, operation_contract_request_schema(state.working_head, action.atomic_id)) if not isinstance(validation, WorkflowError) and action is not None else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - action = state.pending_action - stale = reject_stale_head(state, validation.working_head) - if stale: - payload = stale.payload() - elif action is None or validation.atomic_id != action.atomic_id: - payload = WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Only the pending action's operation contract is available.").payload() - else: - payload = self._operation_payload(task_id, state) - observed.add("contract") - yield "tool_call", self._event(task_id, name, payload, "error" if "code" in payload else "success", usage) + payload = self._topology_payload(task_id, state, validation.kind, validation.limit) + observed.add("topology") + yield "tool_call", self._event(task_id, name, payload, "success", usage) feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:] continue fragment = canonical_json_object(raw) @@ -618,6 +561,29 @@ class WorkflowCoordinator: continue yield self._service_failure(task_id, state, review) return + candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") or {} + action = state.pending_action + if action is None: + yield self._storage_failure(task_id, "Candidate action is unavailable during review.") + return + coverage = [] + for item in candidate.get("claim_results") or (): + if not isinstance(item, dict): + continue + status = str(item.get("status") or "pending") + coverage.append({ + "claim_id": str(item.get("claim_id") or ""), + "status": status if status in {"pass", "pending", "fail", "not_applicable"} else "fail", + "evidence_refs": [], + }) + review = CandidateReview( + candidate_id=state.candidate_id, + working_head=action.working_head, + verdict=review.verdict, + claim_coverage=coverage, + evidence=review.evidence, + issues=review.issues, + ) command = self.actions.record_candidate_review(task_id, review, invocation_id=self._invocation_id(task_id)) if isinstance(command, Rejected): terminal = self._model_rejection_or_service_failure(task_id, state, "review_candidate", command.error, format_errors, feedback, actor="reviewer") @@ -631,6 +597,15 @@ class WorkflowCoordinator: if state.phase == TaskPhase.FINAL_VALIDATION: recovered = self.actions.recover_final_review(task_id) if recovered is not None: + if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed": + completed_state = self.repository.get_state(task_id) + if completed_state is not None: + try: + self._ensure_recovered_completion_result(task_id, completed_state) + except OSError as error: + yield self._storage_failure(task_id, str(error)) + return + yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} yield "final_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True} if isinstance(recovered, Rejected): yield self._service_failure(task_id, state, recovered.error) @@ -652,6 +627,41 @@ class WorkflowCoordinator: continue yield self._service_failure(task_id, state, review) return + facts = self.actions._facts(task_id, state.active_revision) + claim_results = self.actions._evaluate_claims(task_id, facts) + visual_decisions = iter(review.visual_claims) + coverage = [] + for item in claim_results: + if item.get("deterministic"): + status = str(item.get("status") or "fail") + status = status if status in {"pass", "pending", "fail", "not_applicable"} else "fail" + else: + status = next(visual_decisions).status + coverage.append({"claim_id": str(item.get("claim_id") or ""), "status": status, "evidence_refs": []}) + stateless_review = review + review = FinalReview( + working_head=state.working_head, + verdict=review.verdict, + claim_coverage=coverage, + evidence=review.evidence, + issues=review.issues, + ) + will_complete = ( + stateless_review.verdict == "pass" + and all(item.get("status") == "pass" for item in claim_results if item.get("deterministic")) + and all(item.status == "pass" for item in stateless_review.visual_claims) + ) + if will_complete: + try: + self.requirements.write_completion_result( + task_id, + state, + claim_results=claim_results, + review=stateless_review.model_dump(mode="json"), + ) + except OSError as error: + yield self._storage_failure(task_id, str(error)) + return command = self.actions.record_final_review(task_id, review, invocation_id=self._invocation_id(task_id)) if isinstance(command, Rejected): terminal = self._model_rejection_or_service_failure(task_id, state, "review_final", command.error, format_errors, feedback, actor="reviewer") @@ -660,6 +670,8 @@ class WorkflowCoordinator: yield terminal return continue + if isinstance(command, Accepted) and command.payload.get("status") == "completed": + yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)} continue state = self.repository.get_state(task_id) @@ -798,50 +810,50 @@ class WorkflowCoordinator: self.repository.record_usage(task_id, usage) return name, raw, usage - async def _review_requirements(self, task_id: str, reviewer: ModelIdentity, feedback: list[dict[str, Any]] | None = None) -> RequirementsReview | WorkflowError: - state = self.repository.get_state(task_id) - evaluation_context = self.requirements.evaluation_review_context(task_id) - instruction = "Return only structured findings; the service derives coverage and the overall decision. Omit a draft item when it has no finding. Bind every finding to one current draft_id and only source_ids cited by that draft. Use missing_source_semantics for omitted source meaning, claim_mismatch for an incorrect deterministic claim, verification_gap only when a scoped visual claim covers a property unavailable to deterministic verifiers, derivable_conflict only with a registered normalization payload, and ambiguous_conflict only when multiple reasonable interpretations remain; ambiguous conflicts require one precise answerable question. For a full-circle equally spaced pattern, 360/count is the only registered automatic rule. Every independently measurable number, count, dimension, relationship, orientation, material/unit constraint, and single-body requirement must have executable or scoped visual coverage. Do not demand an invented deterministic claim for a verifier gap and do not invent requirements absent from the sources." - if evaluation_context is not None: - instruction += " The evaluation-only known_validation_capability_gaps are authoritative facts explicitly unavailable to the current deterministic verifier registry, not author errors. Do not request an invented executable claim for one of those gaps. When an otherwise covered draft item has a scoped visual acceptance claim for a declared gap, emit verification_gap once; the service will freeze it with an explicit risk and require final independent visual review." - return await self._review_tool(task_id, reviewer, "review_requirements", RequirementsReview, { - "source_index": self.artifacts.read_source_index(task_id), "draft": self._requirements_draft(state), "user_clarifications": self._user_clarifications(task_id), - "evaluation_context": evaluation_context, - "instruction": instruction, - }, schema=self.requirements.review_schema(task_id), feedback=feedback) - - async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> CandidateReview | WorkflowError: + async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessCandidateReview | WorkflowError: candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") action = state.pending_action if not isinstance(candidate, dict) or action is None: return WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review facts are unavailable.", retryable=True) - claim_ids = [str(item.get("claim_id") or "") for item in candidate.get("claim_results") or () if isinstance(item, dict) and item.get("claim_id")] - return await self._review_tool(task_id, reviewer, "review_candidate", CandidateReview, { - # Candidate review is checkpoint-scoped. The frozen contract is - # enough for this decision; source text is retained only for the - # final review where it guards against a frozen-contract omission. - "requirements_contract": self._requirements_contract(task_id, state), - "candidate_id": state.candidate_id, "working_head": action.working_head, - "action": {"action_id": action.action_id, "intent": action.intent, "expected_change": action.expected_change, "requirement_ids": list(action.requirement_ids)}, - "candidate": candidate, "render_manifest": candidate.get("render_manifest") or {}, - "instruction": "Independently review only this checkpoint against the supplied action, not as the finished model. Return every provided claim ID exactly once. A deterministic claim with status pending is explicitly deferred to a later action: cover it as pending and still return verdict accept unless this candidate contradicts the current action, violates a global invariant, or the renders show this action itself is wrong. In particular, when the action establishes a flange base, the absence of a later through bore is pending and MUST NOT cause rejection. Deterministic results are evidence and cannot be overridden.", - }, schema=candidate_review_schema(state.candidate_id, action.working_head, claim_ids), feedback=feedback) + return await self._review_tool(task_id, reviewer, "review_candidate", StatelessCandidateReview, { + "requirements": self._public_requirements(self._requirements_contract(task_id, state)), + "action": {"intent": action.intent, "expected_change": action.expected_change, "operation": action.atomic_id}, + "candidate_facts": self._public_candidate(candidate), + "render_manifest": candidate.get("render_manifest") or {}, + "instruction": "Review only whether the current checkpoint correctly performs the stated action. Deterministic facts are authoritative. Do not return task, action, candidate, requirement, claim, revision, head, or evidence identifiers.", + }, feedback=feedback) - async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> FinalReview | WorkflowError: + async def _observe_images(self, task_id: str, reviewer: ModelIdentity, image_paths: list[str]) -> ImageObservation | WorkflowError: + return await self._review_tool(task_id, reviewer, "observe_images", ImageObservation, { + "source_requirements": self.artifacts.read_source_requirements(task_id), + "reference_image_paths": image_paths, + "instruction": ( + "Inspect every supplied reference image once. Describe visible part geometry, view directions, readable dimensions, holes and profiles, confidence, assumptions, and uncertainties. " + "Do not create CAD operations and do not return attachment or runtime identifiers." + ), + }) + + async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessFinalReview | WorkflowError: facts = self.actions._facts(task_id, state.active_revision) results = self.actions._evaluate_claims(task_id, facts) - return await self._review_tool(task_id, reviewer, "review_final", FinalReview, { - "source_requirements": self.artifacts.read_source_requirements(task_id), "requirements_contract": self._requirements_contract(task_id, state), - "revision_id": state.active_revision, "working_head": state.working_head, "claim_results": results, "render_manifest": (facts.get("report") or {}).get("render_manifest") or {}, - "instruction": "Independently review final model evidence. Return every claim ID exactly once. Deterministic results are final gates and cannot be overridden.", - }, schema=final_review_schema(state.working_head, [str(item.get("claim_id") or "") for item in results if item.get("claim_id")]), feedback=feedback) + visual_claims = [item for item in results if not item.get("deterministic")] + return await self._review_tool(task_id, reviewer, "review_final", StatelessFinalReview, { + "source_requirements": self.artifacts.read_source_requirements(task_id), + "requirements": self._public_requirements(self._requirements_contract(task_id, state)), + "deterministic_results": [self._public_claim_result(item) for item in results if item.get("deterministic")], + "visual_claims": [self._public_claim_result(item) for item in visual_claims], + "render_manifest": (facts.get("report") or {}).get("render_manifest") or {}, + "reference_image_paths": self.artifacts.source_image_paths(task_id), + "instruction": "Review the final CAD renders against the original reference images and the ordered visual claims. Return exactly one visual_claims decision for each supplied visual claim, in the same order. Deterministic results are final. Do not return any runtime identifiers.", + }, schema=stateless_final_review_schema(len(visual_claims)), feedback=feedback) async def _review_tool(self, task_id: str, reviewer: ModelIdentity, name: str, model_type: type[T], payload: dict[str, Any], *, schema: dict[str, Any] | None = None, feedback: list[dict[str, Any]] | None = None) -> T | WorkflowError: if feedback: payload = {**payload, "previous_schema_or_state_error": str(feedback[-1].get("content") or "")[:2_000]} try: tool = self._tool(name, schema or model_type) - response = await self.review_gateway.review(kind="requirements" if name == "review_requirements" else "candidate" if name == "review_candidate" else "final", payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id) + kind = "image_observation" if name == "observe_images" else "candidate" if name == "review_candidate" else "final" + response = await self.review_gateway.review(kind=kind, payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id) except AdapterUnavailable as error: error_code = ( ErrorCode.RENDER_SERVICE_UNAVAILABLE @@ -1030,7 +1042,7 @@ class WorkflowCoordinator: tokens = self.runtime.selector_tokens(topology) eligible_tokens = self._selector_tokens_for_contract(contract, tokens) if selector_shape == "required" and len(eligible_tokens) > 16 and "topology" not in seen: - return [self._tool("inspect_topology", topology_request_schema(state.working_head))] + return [self._tool("inspect_topology", StatelessTopologyRequest)] references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) description = "Submit exactly one CDSL feature for the pending action." if action.atomic_id.startswith("hole_"): @@ -1052,66 +1064,36 @@ class WorkflowCoordinator: if not state.repair_required: return [] if self.actions.rollback_available(task_id, state): - return [self._tool("rollback_checkpoint", rollback_checkpoint_schema(state.working_head, list(self.actions.checkpoint_tokens(task_id, state))))] + return [self._tool("rollback_checkpoint", stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))))] if self.actions.repair_action_ready(task_id, state): return [] evidence_refs = list(self.actions.diagnostic_evidence_refs(task_id, state)) if not evidence_refs: return [] - return [self._tool("record_geometry_conclusion", geometry_conclusion_schema(state.working_head, evidence_refs))] - - def _requirements_author_tools(self, task_id: str, draft_schema: dict[str, Any], patch_schema: dict[str, Any]) -> list[dict[str, Any]]: - """Expose exactly one requirement command from persisted draft facts.""" - state = self.repository.get_state(task_id) - draft = self._requirements_draft(state) - review = self._requirements_review(task_id, state) - if isinstance(review, dict) and review.get("decision") == "revise": - return [self._tool("patch_requirements_draft", patch_schema)] - source_ids = set(self.artifacts.read_source_index(task_id)) - covered = { - str(source_id) - for item in draft.get("items") or () - if isinstance(item, dict) - for source_id in item.get("source_ids") or () - } - if draft.get("items") and source_ids.issubset(covered): - return [self._tool("finalize_requirements_draft", EmptyCommand)] - return [self._tool("submit_requirements_draft_batch", draft_schema)] + return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)] def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> list[dict[str, Any]]: state = self.repository.get_state(task_id) if state is None: return [] if state.phase == TaskPhase.DRAFTING_REQUIREMENTS: - draft = self._requirements_draft(state) - review = self._requirements_review(task_id, state) - if isinstance(review, dict) and review.get("decision") == "revise": - current_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")] - example_id = current_ids[0] if current_ids else "draft_001" - instruction = ( - "The independent reviewer requested revisions. Call patch_requirements_draft with exactly one outer patches array. " - f"Each patch nests target_draft_id (for example {example_id!r}) inside patches[], alongside op. " - "For op=replace, item is the complete replacement RequirementInput and MUST NOT include draft_id; draft_id is server-owned. " - "Do not put target_draft_id or item at the top level. The root object must have this exact shape: " - f'{{"patches":[{{"target_draft_id":"{example_id}","op":"replace","item":{{...}}}}]}}. ' - "Then call finalize_requirements_draft for another review. Do not write Markdown." - ) - evaluation_context = self.requirements.evaluation_review_context(task_id) - if evaluation_context is not None: - instruction += " For a declared evaluation-only validation capability gap, an existing scoped visual claim is valid coverage. Preserve it and do not add a made-up deterministic substitute solely to address that gap." - else: - covered = {str(source_id) for item in draft.get("items") or () if isinstance(item, dict) for source_id in item.get("source_ids") or ()} - source_ids = set(self.artifacts.read_source_index(task_id)) - instruction = "Every source is represented in the draft. Call finalize_requirements_draft now; do not write Markdown." if draft.get("items") and source_ids.issubset(covered) else "Create at most 8 structured requirement items that cover every source. Do not write Markdown." - content = {"protocol": "cad.v3", "phase": state.phase.value, "source_index": self.artifacts.read_source_index(task_id), "source_requirements": self.artifacts.read_source_requirements(task_id), "user_clarifications": self._user_clarifications(task_id), "draft": draft, "review": review, "evaluation_context": self.requirements.evaluation_review_context(task_id), "instruction": instruction} + content = { + "protocol": "cad.v3.spec.v1", + "source_requirements": self.artifacts.read_source_requirements(task_id), + "image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"), + "user_clarifications": self._user_clarifications(task_id), + "instruction": ( + "Return one complete bounded requirements specification. Use deterministic claims only when the supplied registry can execute them; otherwise use a scoped visual claim. " + "Unspecified design choices are assumptions and must not block generation. Return clarification only when two quoted source statements cannot both be followed and the user must choose; ask exactly one question. " + "Never return source, task, draft, requirement, claim, revision, candidate, action, head, or evidence identifiers. Do not solve semantic conflicts by changing a user value." + ), + } else: contract = self._requirements_contract(task_id, state) or {} compact = [{ - "requirement_id": item.get("requirement_id"), "statement": item.get("statement"), "acceptance_claims": [ { - "claim_id": claim.get("claim_id"), "claim_kind": claim.get("claim_kind"), "expected": claim.get("expected"), } @@ -1135,18 +1117,10 @@ class WorkflowCoordinator: for token, value in tokens.items() if token in allowed ][:16] instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated." - content = {"protocol": "cad.v3", "phase": state.phase.value, "working_head": state.working_head, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "applied_normalizations": contract.get("applied_normalizations") or [], "claim_coverage": self.actions.claim_summary(task_id, state), "model_summary": self.actions.model_summary(task_id, state), "active_revision": state.active_revision, "pending_action": self._pending_context(state), "operation_contract": operation_payload, "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "recent_ledger": (self.repository.get_task_projection(task_id) or {}).get("action_ledger_summary", [])[-4:], "diagnostic_evidence_refs": list(self.actions.diagnostic_evidence_refs(task_id, state)) if state.repair_required else [], "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction} + content = {"protocol": "cad.v3", "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction} messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] return [*messages, *feedback[-2:]] - def _requirements_draft(self, state: TaskState | None) -> dict[str, Any]: - if state is None: - return {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []} - return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path) - - def _requirements_review(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None: - return self.artifacts.read_requirements_review(task_id, state.requirements_review_path if state is not None else "") - def _user_clarifications(self, task_id: str) -> list[dict[str, str]]: clarifications: list[dict[str, str]] = [] for event in self.repository.ledger_events(task_id): @@ -1162,32 +1136,12 @@ class WorkflowCoordinator: def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]: """Expose the persisted requirement question when a task is parked. - The review artifact is the durable source of a human decision. The - terminal event deliberately carries only its explicit questions, not - the full reviewer report, so it remains useful to both SSE clients and - conversation history without leaking unrelated review detail. + The clarification artifact is the durable source of the single human + decision needed to continue this task. """ - review = self._requirements_review(task_id, state) or {} - questions: list[str] = [] - unresolved: list[dict[str, str]] = [] - for finding in review.get("findings") or (): - if not isinstance(finding, dict): - continue - question = str(finding.get("question") or "").strip() - finding_type = str(finding.get("finding_type") or "") - if finding_type == "ambiguous_conflict" and question and question not in questions: - questions.append(question) - if finding_type == "ambiguous_conflict": - unresolved.append({ - "draftId": str(finding.get("draft_id") or ""), - "reasonCode": finding_type, - "question": question, - }) - issues = [ - str(finding.get("description") or "").strip() - for finding in review.get("findings") or () - if isinstance(finding, dict) and str(finding.get("description") or "").strip() - ] + clarification = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None + question = str((clarification or {}).get("question") or "").strip() + questions = [question] if question else [] if not questions: raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question") message = f"Requirements need a user decision. {questions[0]}" @@ -1198,14 +1152,10 @@ class WorkflowCoordinator: "code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value, "message": message, "questions": questions, - "reviewPath": state.requirements_review_path, + "clarificationPath": state.clarification_path, "blockerType": "requirements_ambiguity", "userActionRequired": True, } - if issues: - payload["issues"] = issues - if unresolved: - payload["unresolved"] = unresolved return payload def _requirements_contract(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None: @@ -1213,6 +1163,85 @@ class WorkflowCoordinator: return None return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) + def _ensure_recovered_completion_result(self, task_id: str, state: TaskState) -> None: + result_path = self.artifacts.artifact_path(task_id, "completion-result.md") + if result_path.is_file(): + return + facts = self.actions._facts(task_id, state.active_revision) + claim_results = self.actions._evaluate_claims(task_id, facts) + raw_review = self.artifacts.read_json(task_id, f"reviews/final/{state.active_revision}/final-review.json") or {} + coverage = { + str(item.get("claim_id") or ""): item + for item in raw_review.get("claim_coverage") or () + if isinstance(item, dict) + } + visual_claims = [ + { + "status": str(coverage.get(str(item.get("claim_id") or ""), {}).get("status") or "fail"), + "evidence": "; ".join(str(value) for value in raw_review.get("evidence") or ()) or "Recovered final review decision.", + } + for item in claim_results + if not item.get("deterministic") + ] + self.requirements.write_completion_result( + task_id, + state, + claim_results=claim_results, + review={"visual_claims": visual_claims}, + ) + + @staticmethod + def _public_requirements(contract: dict[str, Any] | None) -> list[dict[str, Any]]: + return [ + { + "statement": str(item.get("statement") or ""), + "assumptions": list(item.get("assumptions") or []), + "acceptance_claims": [ + { + "claim_kind": str(claim.get("claim_kind") or ""), + "expected": claim.get("expected") or {}, + "verification_mode": str(claim.get("verification_mode") or ""), + } + for claim in item.get("acceptance_claims") or () + if isinstance(claim, dict) + ], + } + for item in (contract or {}).get("requirements") or () + if isinstance(item, dict) + ] + + @staticmethod + def _public_claim_result(item: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in item.items() + if key not in {"claim_id", "requirement_id", "evidence_refs"} + } + + @classmethod + def _public_candidate(cls, candidate: dict[str, Any]) -> dict[str, Any]: + return { + "claim_results": [cls._public_claim_result(item) for item in candidate.get("claim_results") or () if isinstance(item, dict)], + "operation_verifier_results": [cls._public_claim_result(item) for item in candidate.get("operation_verifier_results") or () if isinstance(item, dict)], + "health": candidate.get("health") or {}, + "model_summary": candidate.get("model_summary") or {}, + } + + @staticmethod + def _public_pending_context(state: TaskState) -> dict[str, Any] | None: + action = state.pending_action + return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None + + @staticmethod + def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(payload, dict): + return None + return { + "operation": payload.get("atomic_id"), + "contract": payload.get("contract"), + "fragment_schema": payload.get("fragment_schema"), + } + def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]: projection = self.repository.get_task_projection(task_id) or {} return { @@ -1227,7 +1256,6 @@ class WorkflowCoordinator: "userActionRequired": bool(projection.get("user_action_required")), "verificationStatus": str(projection.get("verification_status") or "verified"), "verificationWarnings": projection.get("verification_warnings") or [], - "appliedNormalizations": projection.get("applied_normalizations") or [], } def _operation_payload(self, task_id: str, state: TaskState) -> dict[str, Any]: @@ -1429,6 +1457,48 @@ class WorkflowCoordinator: "field_errors": list(error.field_errors), } + def _requirements_format_failure( + self, + task_id: str, + state: TaskState, + error: WorkflowError, + counters: dict[str, int], + feedback: list[dict[str, Any]], + ) -> tuple[str, dict[str, Any]] | None: + key = "requirements_spec" + counters[key] = counters.get(key, 0) + 1 + feedback[:] = [self._feedback(error)] + if counters[key] < 2: + return None + failed = transition(state, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID) + self.repository.compare_and_swap(failed, events=[{ + "event": "requirements_spec_invalid", + "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, + "message": error.message, + "field_errors": list(error.field_errors), + }]) + return "task_terminal", { + "taskId": task_id, + "lifecycle": "failed", + "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, + "message": "Requirements specification remained unreadable after one field-level correction.", + "tool": "submit_requirements_spec", + "field_errors": list(error.field_errors), + "userActionRequired": False, + } + + def _requirements_rejection( + self, + task_id: str, + state: TaskState, + error: WorkflowError, + counters: dict[str, int], + feedback: list[dict[str, Any]], + ) -> tuple[str, dict[str, Any]] | None: + if error.retryable or error.code == ErrorCode.STORAGE_FAILURE: + return self._service_failure(task_id, state, error) + return self._requirements_format_failure(task_id, state, error, counters, feedback) + @staticmethod def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]: parameters = model if isinstance(model, dict) else model.model_json_schema() diff --git a/backend/app/cad_agent/composition.py b/backend/app/cad_agent/composition.py index 07c6789c..e6fb469e 100644 --- a/backend/app/cad_agent/composition.py +++ b/backend/app/cad_agent/composition.py @@ -13,7 +13,7 @@ from app.cad_agent.adapters.structured_llm import StructuredModelGateway from app.cad_agent.adapters.verifier import RegistryVerifierExecutor from app.cad_agent.application.action_handlers import ActionCommandHandler from app.cad_agent.application.outbox import OutboxDispatcher -from app.cad_agent.application.requirements_review import RequirementsCommandHandler +from app.cad_agent.application.requirements import RequirementsCommandHandler from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator from app.cad_agent.domain.verifier_registry import default_registry from app.settings import Settings @@ -48,7 +48,6 @@ def compose_v3(settings: Settings) -> V3Services: WorkflowConfig( max_turns=max(8, settings.agent_tool_calls_per_cycle * 8), format_error_limit=settings.agent_format_error_repeat_limit, - requirements_review_limit=3, author_fallbacks=fallbacks, ), repository, diff --git a/backend/app/cad_agent/domain/errors.py b/backend/app/cad_agent/domain/errors.py index be2b4cf6..cae51a4a 100644 --- a/backend/app/cad_agent/domain/errors.py +++ b/backend/app/cad_agent/domain/errors.py @@ -20,12 +20,13 @@ class ErrorCode(StrEnum): VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE" CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED" MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED" + MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING" AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE" REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE" RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE" STORAGE_FAILURE = "STORAGE_FAILURE" CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED" - REQUIREMENTS_REVIEW_NOT_CONVERGED = "REQUIREMENTS_REVIEW_NOT_CONVERGED" + REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID" NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT" RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE" FAILED_INTERNAL = "FAILED_INTERNAL" diff --git a/backend/app/cad_agent/domain/state.py b/backend/app/cad_agent/domain/state.py index bae86135..bb38aa5f 100644 --- a/backend/app/cad_agent/domain/state.py +++ b/backend/app/cad_agent/domain/state.py @@ -10,7 +10,6 @@ from .errors import ErrorCode, WorkflowError class TaskPhase(StrEnum): DRAFTING_REQUIREMENTS = "DRAFTING_REQUIREMENTS" - REVIEWING_REQUIREMENTS = "REVIEWING_REQUIREMENTS" AWAITING_ACTION = "AWAITING_ACTION" ACTION_PENDING = "ACTION_PENDING" CANDIDATE_BUILDING = "CANDIDATE_BUILDING" @@ -47,8 +46,8 @@ class TaskState: repair_required: bool = False last_error: ErrorCode | None = None retry_from_phase: TaskPhase | None = None - requirements_draft_path: str = "" - requirements_review_path: str = "" + requirements_spec_path: str = "" + clarification_path: str = "" requirements_contract_path: str = "" @property @@ -59,14 +58,12 @@ class TaskState: # Legal state transitions. Events are intentionally terse persistence-neutral # names used by command handlers and architecture tests. _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = { - (TaskPhase.DRAFTING_REQUIREMENTS, "draft_updated"): TaskPhase.DRAFTING_REQUIREMENTS, - (TaskPhase.DRAFTING_REQUIREMENTS, "requirements_finalized"): TaskPhase.REVIEWING_REQUIREMENTS, - (TaskPhase.REVIEWING_REQUIREMENTS, "requirements_approved"): TaskPhase.AWAITING_ACTION, - (TaskPhase.REVIEWING_REQUIREMENTS, "requirements_revise"): TaskPhase.DRAFTING_REQUIREMENTS, - (TaskPhase.REVIEWING_REQUIREMENTS, "waiting_for_user"): TaskPhase.WAITING_FOR_USER, - # User clarifications are durable task evidence. Requirement-level pauses - # resume on the same task so the frozen request and draft history remain - # reviewable instead of turning a short reply into a new CAD request. + (TaskPhase.DRAFTING_REQUIREMENTS, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS, + (TaskPhase.DRAFTING_REQUIREMENTS, "requirements_approved"): TaskPhase.AWAITING_ACTION, + (TaskPhase.DRAFTING_REQUIREMENTS, "waiting_for_user"): TaskPhase.WAITING_FOR_USER, + # User clarifications are durable task evidence. Resume on the same task + # so its frozen request remains authoritative + # instead of turning a clarification into a new CAD request. (TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS, (TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING, (TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION, @@ -98,7 +95,6 @@ _TRANSITIONS.update({ }) _RETRY_RESUMABLE_PHASES = frozenset({ TaskPhase.DRAFTING_REQUIREMENTS, - TaskPhase.REVIEWING_REQUIREMENTS, TaskPhase.AWAITING_ACTION, TaskPhase.ACTION_PENDING, TaskPhase.CANDIDATE_BUILDING, @@ -126,7 +122,7 @@ def retry_resume_event(state: TaskState) -> str | None: return f"resume_{state.retry_from_phase.value.lower()}" -def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_draft_path: str | None = None, requirements_review_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState: +def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState: """Apply one legal transition and advance optimistic-concurrency version.""" target = _TRANSITIONS.get((state.phase, event)) if target is None: @@ -147,8 +143,8 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction | repair_required=state.repair_required if repair_required is None else repair_required, last_error=error, retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None, - requirements_draft_path=state.requirements_draft_path if requirements_draft_path is None else requirements_draft_path, - requirements_review_path=state.requirements_review_path if requirements_review_path is None else requirements_review_path, + requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path, + clarification_path=state.clarification_path if clarification_path is None else clarification_path, requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path, ) diff --git a/backend/app/cad_agent/evals/live.py b/backend/app/cad_agent/evals/live.py index d18a7765..9362f83e 100644 --- a/backend/app/cad_agent/evals/live.py +++ b/backend/app/cad_agent/evals/live.py @@ -172,7 +172,7 @@ def _acceptance_coverage( claim for claim in expected_claims if not any( actual_claim["claim_kind"] == claim["claim_kind"] - and contains_expected(actual_claim["expected"], claim["expected"]) + and _contains_business_expected(actual_claim["expected"], claim["expected"]) for actual_claim in actual ) ] @@ -186,8 +186,26 @@ def _acceptance_coverage( } +def _contains_business_expected(actual: Any, required: Any) -> bool: + """Match fixture business values without coupling to verifier tolerances. + + Tolerances are executable verifier parameters chosen within the schema's + safe range. They are not a separate user requirement and should not make + a valid generated contract fail release evaluation merely because the + author used the registry default instead of the fixture's tighter value. + """ + if isinstance(actual, dict) and isinstance(required, dict): + return all( + key in actual + and _contains_business_expected(actual[key], value) + for key, value in required.items() + if key not in {"tolerance_mm", "tolerance"} + ) + return contains_expected(actual, required) + + def _contains_expected(actual: Any, required: Any) -> bool: - """Backward-compatible evaluation helper for canonical claim matching.""" + """Match canonical claim values in release evaluation.""" return contains_expected(actual, required) @@ -490,7 +508,12 @@ def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) - def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]: """Hash reviewable CAD evidence without copying source or prompt content.""" - allowed_exact = {"requirements-contract.json", "requirements-index.json", "requirements.md", "completion.md"} + allowed_exact = { + "requirements-contract.json", + "requirements.md", + "completion-target.md", + "completion-result.md", + } allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-") files: list[dict[str, str]] = [] if artifact_root.is_dir(): @@ -550,8 +573,8 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An ] verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema()) try: - author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, force=True) - reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, force=True) + author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True) + reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True) except Exception as error: return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]} if not author_capability.get("supported") or not reviewer_capability.get("supported"): diff --git a/backend/app/cad_agent/ports.py b/backend/app/cad_agent/ports.py index 8f472063..bf664b74 100644 --- a/backend/app/cad_agent/ports.py +++ b/backend/app/cad_agent/ports.py @@ -58,15 +58,13 @@ class TaskRepository(Protocol): class ArtifactStore(Protocol): - def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None) -> None: ... + def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ... def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ... def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ... def read_source_index(self, task_id: str) -> dict[str, str]: ... def read_source_requirements(self, task_id: str) -> str: ... - def read_requirements_draft(self, task_id: str, artifact_path: str = "") -> dict[str, Any]: ... - def write_requirements_draft(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str: ... - def read_requirements_review(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ... - def write_requirements_review(self, task_id: str, payload: dict[str, Any], *, invocation_id: str) -> str: ... + def source_image_paths(self, task_id: str) -> list[str]: ... + def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ... def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ... def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ... def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ... diff --git a/backend/app/main.py b/backend/app/main.py index d7a860a8..dd15f146 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,9 +25,8 @@ app = FastAPI(title="CDSL CAD Agent API", version="0.1.0") @app.on_event("startup") async def resume_autonomous_generation() -> None: - """Restore durable autonomous agent tasks after a backend process restart.""" - if settings.resume_running_tasks_on_startup: - await agent.resume_running_tasks() + """Prewarm model protocols without delaying API readiness.""" + asyncio.create_task(agent.resume_running_tasks(), name="cad-model-protocol-prewarm") @app.get("/health") @@ -152,13 +151,16 @@ async def read_task(task_id: str) -> JSONResponse: raise HTTPException(status_code=404, detail="Task not found") task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") state = agent.v3.repository.get_state(safe_id) - draft = agent.v3.artifacts.read_requirements_draft(safe_id, state.requirements_draft_path) if state is not None else None - task["requirements_draft"] = draft - task["requirements_review"] = agent.v3.artifacts.read_requirements_review(safe_id, state.requirements_review_path) if state is not None else None + task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary")) task["requirements_markdown"] = (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").read_text(encoding="utf-8") if (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").is_file() else None - task["completion_markdown"] = (agent.v3.artifacts.task_dir(safe_id) / "completion.md").read_text(encoding="utf-8") if (agent.v3.artifacts.task_dir(safe_id) / "completion.md").is_file() else None + target_path = agent.v3.artifacts.task_dir(safe_id) / "completion-target.md" + result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md" + task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path.is_file() else None + task["completion_target_path"] = "completion-target.md" if target_path.is_file() else "" + task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None + task["completion_result_path"] = "completion-result.md" if result_path.is_file() else "" task["usage"] = agent.v3.repository.usage_summary(safe_id) return JSONResponse(task) diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index bf8088ee..4627d768 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -10,7 +10,7 @@ import secrets from typing import Any from app.cad_agent.application.workflow import ModelIdentity -from app.cad_agent.application.capabilities import verify_model_capability +from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability from app.cad_agent.composition import V3Services, compose_v3 from app.cad_agent.domain.errors import ErrorCode from app.cad_agent.domain.state import TaskPhase, transition @@ -32,7 +32,10 @@ def _response_language(text: str) -> str: _EVENT_LABELS = { - "requirements_review": "需求合同独立复核", + "image_observation": "参考图片观察", + "requirements_ready": "需求规格已就绪", + "completion_result_ready": "完成结果已就绪", + "model_protocol_check": "模型协议检查", "action_selection": "动作选择", "tool_call": "建模工具", "candidate_result": "候选构建", @@ -72,8 +75,6 @@ class AgentService: async def resume_running_tasks(self) -> None: task_ids = self.v3.repository.running_task_ids() - if not task_ids: - return try: author_provider, author_model = self.settings.resolve_model(None, None) review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) @@ -86,19 +87,23 @@ class AgentService: ) return try: - author_capability = await verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=author_provider.id, - model_id=author_model.id, - ) - reviewer_capability = await verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=review_provider.id, - model_id=review_model.id, + author_capability, reviewer_capability = await asyncio.gather( + verify_model_capability( + self.v3.repository, + self.v3.workflow.runtime, + self.v3.models, + provider_id=author_provider.id, + model_id=author_model.id, + role="author", + ), + verify_model_capability( + self.v3.repository, + self.v3.workflow.runtime, + self.v3.models, + provider_id=review_provider.id, + model_id=review_model.id, + role="reviewer", + ), ) except Exception as error: # Startup recovery must never bypass a production capability gate. @@ -111,6 +116,14 @@ class AgentService: retryable=True, ) return + if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"): + await self._park_startup_tasks( + task_ids, + ErrorCode.MODEL_PROTOCOL_CHECK_PENDING, + "Model protocol check is temporarily unavailable.", + retryable=True, + ) + return if not author_capability.get("supported") or not reviewer_capability.get("supported"): await self._park_startup_tasks( task_ids, @@ -119,6 +132,8 @@ class AgentService: retryable=False, ) return + if not self.settings.resume_running_tasks_on_startup: + return for task_id in task_ids: if task_id in self._autonomous_runs: continue @@ -213,6 +228,7 @@ class AgentService: self.v3.models, provider_id=author_provider.id, model_id=author_model.id, + role="author", ) reviewer_capability = await verify_model_capability( self.v3.repository, @@ -220,6 +236,7 @@ class AgentService: self.v3.models, provider_id=review_provider.id, model_id=review_model.id, + role="reviewer", ) if not author_capability.get("supported") or not reviewer_capability.get("supported"): raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite") @@ -260,14 +277,15 @@ class AgentService: if state is not None: terminal = self.v3.workflow.waiting_for_user_terminal(selected, state) fields = [ - { - "path": f"/requirements/{str(item.get('draftId') or 'review')}", - "message": str(item.get("question") or item.get("reasonCode") or "Unresolved review item."), - } - for item in terminal.get("unresolved") or () - if isinstance(item, dict) + {"path": "/requirements/clarification", "message": str(question)} + for question in terminal.get("questions") or () + if str(question).strip() ] - fields.extend({"path": "/requirements/review", "message": issue} for issue in terminal.get("issues") or ()) + fields.extend( + {"path": "/requirements", "message": str(issue)} + for issue in terminal.get("issues") or () + if str(issue).strip() + ) if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id): yield event("cad_error", { "stage": "request", @@ -280,35 +298,16 @@ class AgentService: yield event("done", {}) return resumed_task_id = selected - try: - author_provider, author_model = self.settings.resolve_model(provider_id, model_id) - review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) - except ValueError as error: - yield event("cad_error", {"stage": "configuration", "message": str(error)}) - yield event("done", {}) - return - try: - author_capability = await verify_model_capability(self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=author_provider.id, model_id=author_model.id) - reviewer_capability = await verify_model_capability(self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=review_provider.id, model_id=review_model.id) - except Exception as error: - yield event("cad_error", {"stage": "configuration", "message": f"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: {str(error)[:500]}"}) - yield event("done", {}) - return - if not author_capability.get("supported") or not reviewer_capability.get("supported"): - yield event("cad_error", {"stage": "configuration", "message": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite."}) - yield event("done", {}) - return if current and str(current.get("lifecycle") or "") == "running": yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"}) yield event("done", {}) return - # A terminal task is immutable. A legacy task is never migrated: both - # cases intentionally create a fresh v3 task. + # A terminal task is immutable; follow-up text creates a new task. task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}" if not resumed_task_id: try: - source_blocks = self._task_source_blocks(conversation, request) - self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks) + source_blocks, image_inputs = self._task_inputs(conversation, request) + self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) except ValueError as error: yield event("cad_error", {"stage": "request", "message": str(error)}) yield event("done", {}) @@ -316,6 +315,24 @@ class AgentService: conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id) yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."}) + try: + author_provider, author_model = self.settings.resolve_model(provider_id, model_id) + review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) + except ValueError as error: + state = self.v3.repository.get_state(task_id) + if state is not None: + failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) + self.v3.repository.compare_and_swap(failed, events=[{ + "event": "model_configuration_invalid", + "message": str(error)[:1000], + "issues": [str(error)[:1000]], + }]) + terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False} + yield event("task_terminal", terminal) + yield event("cad_error", {"stage": "configuration", "message": str(error)}) + yield event("done", {}) + return + queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() parts: list[dict[str, Any]] = [] sequence = 0 @@ -345,6 +362,18 @@ class AgentService: await queue.put(("progress", _visible_progress("state_changed", payload))) try: + capability_terminal = await self._ensure_task_capabilities( + task_id, + ModelIdentity(author_provider.id, author_model.id), + ModelIdentity(review_provider.id, review_model.id), + queue, + ) + if capability_terminal is not None: + sequence += 1 + capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()} + _upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)}) + await queue.put(("task_terminal", capability_terminal)) + return async for name, payload in self.v3.workflow.run( task_id=task_id, author=ModelIdentity(author_provider.id, author_model.id), @@ -393,9 +422,82 @@ class AgentService: yield event(name, payload) yield event("done", {}) - def _task_source_blocks(self, conversation: dict[str, Any], request: str) -> list[dict[str, Any]]: + async def _ensure_task_capabilities( + self, + task_id: str, + author: ModelIdentity, + reviewer: ModelIdentity, + queue: asyncio.Queue[tuple[str, dict[str, Any]] | None], + ) -> dict[str, Any] | None: + roles = (("author", author), ("reviewer", reviewer)) + cached = { + role: cached_model_capability( + self.v3.repository, + self.v3.workflow.runtime, + provider_id=model.provider_id, + model_id=model.model_id, + role=role, + ) + for role, model in roles + } + unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")] + if unsupported: + return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}") + missing = [(role, model) for role, model in roles if cached[role] is None] + if not missing: + return None + + state = self.v3.repository.get_state(task_id) + if state is None: + return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False} + waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) + if not self.v3.repository.compare_and_swap(waiting, events=[{ + "event": "model_protocol_check_pending", + "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, + "message": "模型协议检查中,完成后将自动继续。", + }]): + return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False} + await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"})) + try: + results = await asyncio.gather(*( + verify_model_capability( + self.v3.repository, + self.v3.workflow.runtime, + self.v3.models, + provider_id=model.provider_id, + model_id=model.model_id, + role=role, + ) + for role, model in missing + )) + except Exception as error: + return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False} + unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")] + if unavailable: + return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False} + unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")] + if unsupported: + return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}") + if not self.v3.workflow.resume(task_id): + return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False} + await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"})) + return None + + def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]: + state = self.v3.repository.get_state(task_id) + if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}: + failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) + self.v3.repository.compare_and_swap(failed, events=[{ + "event": "model_protocol_unsupported", + "message": message, + "issues": [message], + }]) + return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False} + + def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: """Freeze message paragraphs and attachment blocks before task creation.""" blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()] + image_inputs: list[dict[str, str]] = [] conversation_id = str(conversation.get("conversation_id") or "") if not conversation_id: raise ValueError("Conversation has no identifier") @@ -432,6 +534,11 @@ class AgentService: f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). " "It is a visual reference and requires explicit visual verification." ) + image_inputs.append({ + "path": str(binary_path), + "mime": str(attachment.get("mime") or "image/*"), + "sha256": expected_digest, + }) else: raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}") if not text: @@ -446,4 +553,4 @@ class AgentService: "sha256": expected_digest, }, }) - return blocks + return blocks, image_inputs diff --git a/backend/tests/test_cad_agent_v3.py b/backend/tests/test_cad_agent_v3.py index 55de50d5..2121ca0e 100644 --- a/backend/tests/test_cad_agent_v3.py +++ b/backend/tests/test_cad_agent_v3.py @@ -1,12 +1,11 @@ from __future__ import annotations import asyncio -import ast -from dataclasses import replace +import base64 from hashlib import sha256 import json -import math from pathlib import Path +import sqlite3 import sys import tempfile import unittest @@ -18,54 +17,25 @@ sys.path.insert(0, str(ROOT / "backend")) from app.cad_agent.adapters.artifact_store import FileArtifactStore from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher -from app.cad_agent.adapters.runtime import ProfileCadRuntime, RuntimeAdapterError +from app.cad_agent.adapters.review_gateway import RenderedReviewGateway +from app.cad_agent.adapters.runtime import ProfileCadRuntime from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository -from app.cad_agent.adapters.structured_llm import StructuredModelError, StructuredModelGateway, StructuredTransportError -from app.cad_agent.adapters.verifier import RegistryVerifierExecutor -from app.cad_agent.application.action_handlers import ActionCommandHandler +from app.cad_agent.application.capabilities import cached_model_capability, conformance_hash, conformance_tools, verify_model_capability +from app.cad_agent.application.llm_contracts import ( + RequirementsAuthorOutput, + StatelessCandidateReview, + requirements_spec_schema, + stateless_final_review_schema, + stateless_next_action_schema, + stateless_rollback_checkpoint_schema, +) from app.cad_agent.application.outbox import OutboxDispatcher +from app.cad_agent.application.requirements import RequirementsCommandHandler from app.cad_agent.application.results import Accepted, Rejected, Waiting -from app.cad_agent.application.capabilities import conformance_tools -from app.cad_agent.application.llm_contracts import ( # noqa: E402 - CandidateReview, - ClaimCoverage, - FinalReview, - GeometryConclusion, - NextAction, - RequirementsDraftBatch, - RequirementsPatchBatch, - RequirementsReview, - RollbackCheckpoint, - canonical_json_object, - canonical_validate_schema, - canonical_validate, - candidate_review_schema, - final_review_schema, - geometry_conclusion_schema, - operation_contract_request_schema, - requirements_draft_schema, - rollback_checkpoint_schema, - topology_request_schema, -) -from app.cad_agent.evals.live import _acceptance_coverage, _capability_block_reason, _failure_attribution, _fixture, _run_checks, _safe_artifact_manifest -from app.cad_agent.evals.token_baseline import ( - BASELINE_SCHEMA_VERSION, - author_request_identity, - compare_token_baseline, - request_sha256, - validate_token_baseline_provenance, -) -from app.cad_agent.application.requirements_review import RequirementsCommandHandler from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator -from app.cad_agent.domain.errors import ErrorCode, WorkflowError -from app.cad_agent.domain.operation_contract import ( - SEMANTIC_PREFLIGHT_NAMES, - OperationContractError, - fragment_schema, - validate_fragment, - validate_operation_contract, -) -from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState, legal_transitions, retry_resume_event, transition +from app.cad_agent.domain.errors import ErrorCode +from app.cad_agent.domain.operation_contract import fragment_schema +from app.cad_agent.domain.state import TaskPhase, TaskState, legal_transitions, retry_resume_event, transition from app.cad_agent.domain.verifier_registry import default_registry from app.models.contracts import ChatMessage from app.services.agent_service import AgentService @@ -75,22 +45,6 @@ from app.settings import ProviderConfig, ProviderModel, Settings def settings(root: Path) -> Settings: - provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) - return Settings( - task_root=root / "tasks", - conversation_root=root / "conversations", - library_root=ROOT / "backend" / "cdsl_library", - engine_root=ROOT / "backend" / "engine" / "cdsl_engine", - llm_base_url=provider.base_url, - llm_api_key=provider.api_key, - llm_model="test-model", - llm_timeout_s=1, - default_provider_id="test", - providers=(provider,), - ) - - -def service_settings(root: Path) -> Settings: author = ProviderConfig("author", "Author", "https://author.invalid/v1", "author-key", (ProviderModel("author-model"),)) reviewer = ProviderConfig("reviewer", "Reviewer", "https://reviewer.invalid/v1", "reviewer-key", (ProviderModel("reviewer-model", vision=True),)) return Settings( @@ -109,3728 +63,325 @@ def service_settings(root: Path) -> Settings: ) -def schema_example(schema: object) -> object: - """Build a smallest valid fixture from a closed operation-param schema.""" - if not isinstance(schema, dict): - raise AssertionError("Schema fixture must be an object") - if "const" in schema: - return schema["const"] - if isinstance(schema.get("enum"), list) and schema["enum"]: - return schema["enum"][0] - for branch_name in ("oneOf", "anyOf"): - branches = schema.get(branch_name) - if isinstance(branches, list) and branches: - return schema_example(branches[0]) - kind = schema.get("type") - if kind == "object": - properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {} - required = schema.get("required") if isinstance(schema.get("required"), list) else [] - return {str(name): schema_example(properties[name]) for name in required if name in properties} - if kind == "array": - count = int(schema.get("minItems") or 0) - return [schema_example(schema.get("items") or {}) for _ in range(count)] - if kind == "integer": - lower = schema.get("minimum", schema.get("exclusiveMinimum", 0)) - return int(lower) + (1 if "exclusiveMinimum" in schema else 0) - if kind == "number": - lower = schema.get("minimum", schema.get("exclusiveMinimum", 0)) - return float(lower) + (1.0 if "exclusiveMinimum" in schema else 0.0) - if kind == "boolean": - return False - if kind == "string": - return "x" * max(1, int(schema.get("minLength") or 0)) - if kind == "null": - return None - raise AssertionError(f"No generic fixture for schema type: {kind!r}") +def ready_spec(*, spacing: float = 60.0) -> RequirementsAuthorOutput: + return RequirementsAuthorOutput.model_validate({ + "outcome": "ready", + "summary": "Circular flange with an eight-hole pattern.", + "assumptions": ["Dimensions use millimetres."], + "requirements": [{ + "statement": f"Use eight holes with a declared spacing of {spacing:g} degrees.", + "assumptions": [], + "acceptance_claims": [{ + "claim_kind": "visual", + "expected": {"description": f"Eight holes are shown with the requested {spacing:g} degree declaration."}, + }], + }], + }) -class V3ProtocolTests(unittest.TestCase): - def _ready_action(self, root: Path) -> tuple[SqliteTaskRepository, FileArtifactStore, ActionCommandHandler, str, PendingAction]: - runtime = ProfileCadRuntime(settings(root)) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - registry = default_registry() - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry)) - task_id = "cad_123456abcdef" - state = repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - contract = { - "schema_version": "cad.requirements-contract.v1", "task_id": task_id, "contract_hash": "test-contract", - "requirements": [{"requirement_id": "req_001", "draft_id": "draft_001", "source_ids": ["src_001"], "statement": "One solid", "assumptions": [], "acceptance_claims": [{"claim_id": "claim_001", "claim_kind": "solid_count_equals", "expected": {"value": 1}}]}], +def walk_keys(value: object) -> set[str]: + keys: set[str] = set() + if isinstance(value, dict): + keys.update(str(key) for key in value) + for item in value.values(): + keys.update(walk_keys(item)) + elif isinstance(value, list): + for item in value: + keys.update(walk_keys(item)) + return keys + + +class CadV3ProtocolTests(unittest.TestCase): + def test_state_machine_has_no_requirements_review_phase(self) -> None: + self.assertNotIn("REVIEWING_REQUIREMENTS", {phase.value for phase in TaskPhase}) + state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS, 0) + approved = transition(state, "requirements_approved", requirements_contract_path="documents/contract.json") + self.assertEqual(approved.phase, TaskPhase.AWAITING_ACTION) + self.assertNotIn("requirements_finalized", {event for _phase, event in legal_transitions()}) + + def test_waiting_retry_resumes_exact_source_phase(self) -> None: + state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS, 0) + waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) + self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements") + resumed = transition(waiting, retry_resume_event(waiting) or "") + self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS) + self.assertIsNone(resumed.retry_from_phase) + + def test_llm_schemas_exclude_server_owned_runtime_ids(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + runtime = ProfileCadRuntime(settings(Path(temporary))) + schemas = [ + requirements_spec_schema(default_registry().expected_one_of_schema()), + stateless_next_action_schema(list(runtime.supported_atomic_ids())), + StatelessCandidateReview.model_json_schema(), + stateless_final_review_schema(2), + ] + forbidden = { + "task_id", "working_head", "requirement_id", "requirement_ids", "claim_id", + "candidate_id", "action_id", "evidence_id", "evidence_refs", "source_id", "source_ids", + "draft_id", "attachment_id", } - artifacts.write_requirements_contract(task_id, contract) - contract_path = artifacts.write_requirements_contract(task_id, contract, invocation_id="test_contract") - for event in ("draft_updated", "requirements_finalized", "requirements_approved"): - state = transition(state, event, requirements_contract_path=contract_path if event == "requirements_approved" else None) - self.assertTrue(repository.compare_and_swap(state)) - operation = runtime.operation_contract("extrude_add_blind") - action = PendingAction("act_004", state.working_head, "Create base", ("req_001",), "extrude_add_blind", "Add one base", operation["contract_hash"], "candidate-key") - state = transition(state, "action_proposed", pending_action=action) - self.assertTrue(repository.compare_and_swap(state)) - return repository, artifacts, actions, task_id, action + for schema in schemas: + self.assertFalse(walk_keys(schema) & forbidden, walk_keys(schema) & forbidden) - def test_canonical_dto_rejects_extra_fields_without_side_effect(self) -> None: - result = canonical_validate( - json.dumps({"working_head": "cad_123456abcdef:root:v1", "intent": "Create base", "requirement_ids": ["req_001"], "atomic_id": "extrude_add_blind", "expected_change": "Add base", "plan_step_id": "old"}), - NextAction, - ) - self.assertEqual(getattr(result, "code", None), ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertTrue(any(error["path"] == "/plan_step_id" for error in result.field_errors)) + def test_dynamic_tokens_are_enum_constrained(self) -> None: + rollback = stateless_rollback_checkpoint_schema(["checkpoint_one"]) + self.assertEqual(rollback["properties"]["checkpoint_token"], {"enum": ["checkpoint_one"]}) + with tempfile.TemporaryDirectory() as temporary: + runtime = ProfileCadRuntime(settings(Path(temporary))) + schema = fragment_schema(runtime.operation_contract("hole_blind"), selector_tokens=["selector_one"], reference_tokens=[]) + selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"] + self.assertEqual(selector, {"enum": ["selector_one"]}) - def test_domain_transition_table_covers_every_legal_and_illegal_edge(self) -> None: - table = legal_transitions() - events = {event for _phase, event in table} - for (phase, event), target in table.items(): - state = TaskState( - "cad_123456abcdef", phase, 7, - retry_from_phase=target if phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") else None, + def test_requirements_spec_freezes_once_and_preserves_user_value(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + handler = RequirementsCommandHandler(repository, artifacts, default_registry()) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Use 8 holes around a full circle at 60 degrees.") + artifacts.initialize_task(task_id, "Use 8 holes around a full circle at 60 degrees.") + result = handler.submit_spec(task_id, ready_spec(spacing=60), invocation_id="requirements_once") + self.assertIsInstance(result, Accepted) + state = repository.get_state(task_id) + self.assertEqual(state.phase, TaskPhase.AWAITING_ACTION) + contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} + requirement = contract["requirements"][0] + self.assertIn("60 degrees", requirement["statement"]) + self.assertIn("60 degree", requirement["acceptance_claims"][0]["expected"]["description"]) + self.assertNotIn("45 degrees", requirement["statement"]) + self.assertNotIn("applied_normalizations", contract) + self.assertTrue((artifacts.task_dir(task_id) / "requirements.md").is_file()) + self.assertTrue((artifacts.task_dir(task_id) / "completion-target.md").is_file()) + self.assertFalse((artifacts.task_dir(task_id) / "completion.md").exists()) + + def test_invalid_verifier_contract_is_rejected_without_state_change(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + handler = RequirementsCommandHandler(repository, artifacts, default_registry()) + task_id = "cad_123456abcdef" + initial = repository.create_task(task_id, "Create one solid.") + artifacts.initialize_task(task_id, "Create one solid.") + value = RequirementsAuthorOutput.model_validate({ + "outcome": "ready", "summary": "One solid", "assumptions": [], + "requirements": [{"statement": "One solid", "assumptions": [], "acceptance_claims": [ + {"claim_kind": "solid_count_equals", "expected": {"value": 0}}, + ]}], + }) + result = handler.submit_spec(task_id, value, invocation_id="invalid_spec") + self.assertIsInstance(result, Rejected) + self.assertEqual(result.error.code, ErrorCode.REQUIREMENTS_SPEC_INVALID) + self.assertEqual(repository.get_state(task_id), initial) + + def test_clarification_uses_same_task_and_one_question(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + runtime = ProfileCadRuntime(settings(root)) + requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) + workflow = WorkflowCoordinator(WorkflowConfig(4, 2), repository, artifacts, runtime, None, None, requirements, None) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Use four holes and use six holes.") + artifacts.initialize_task(task_id, "Use four holes and use six holes.") + value = RequirementsAuthorOutput.model_validate({ + "outcome": "clarification", "source_quotes": ["four holes", "six holes"], + "question": "Should the part use four holes or six holes?", + }) + result = requirements.submit_spec(task_id, value, invocation_id="clarify_once") + self.assertIsInstance(result, Waiting) + waiting = repository.get_state(task_id) + terminal = workflow.waiting_for_user_terminal(task_id, waiting) + self.assertEqual(terminal["questions"], ["Should the part use four holes or six holes?"]) + self.assertTrue(terminal["userActionRequired"]) + self.assertTrue(workflow.resume_with_user_clarification(task_id, "Use six holes.", message_id="user_reply")) + self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS) + + def test_contract_views_and_completion_result_share_one_spec(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + handler = RequirementsCommandHandler(repository, artifacts, default_registry()) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Create a coherent flange.") + artifacts.initialize_task(task_id, "Create a coherent flange.") + handler.submit_spec(task_id, ready_spec(), invocation_id="requirements_once") + state = repository.get_state(task_id) + path = handler.write_completion_result( + task_id, state, + claim_results=[{"claim_id": "claim_001", "deterministic": False, "status": "pending"}], + review={"visual_claims": [{"status": "pass", "evidence": "Reference and render match."}]}, ) - advanced = transition(state, event) - self.assertEqual(advanced.phase, target, f"{phase} --{event}-->") - self.assertEqual(advanced.version, state.version + 1) - for phase in TaskPhase: - for event in events: - if (phase, event) in table: - continue - with self.assertRaises(ValueError, msg=f"{phase} must reject {event}"): - transition(TaskState("cad_123456abcdef", phase, 7), event) + self.assertEqual(path, "completion-result.md") + result = (artifacts.task_dir(task_id) / path).read_text(encoding="utf-8") + target = (artifacts.task_dir(task_id) / "completion-target.md").read_text(encoding="utf-8") + requirements = (artifacts.task_dir(task_id) / "requirements.md").read_text(encoding="utf-8") + self.assertIn("eight-hole pattern", requirements) + self.assertIn("visual", target) + self.assertIn("visual: pass", result) - def test_waiting_retry_resumes_the_persisted_source_phase(self) -> None: + def test_sqlite_schema_contains_only_current_requirement_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + database = Path(temporary) / "state.sqlite3" + SqliteTaskRepository(database) + with sqlite3.connect(database) as connection: + columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")} + self.assertIn("requirements_spec_path", columns) + self.assertIn("clarification_path", columns) + self.assertNotIn("requirements_draft_path", columns) + self.assertNotIn("requirements_review_path", columns) + + def test_role_specific_capability_tools_have_no_review_loop(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + runtime = ProfileCadRuntime(settings(Path(temporary))) + author = conformance_tools(runtime, role="author") + reviewer = conformance_tools(runtime, role="reviewer") + author_names = {item["function"]["name"] for item in author} + reviewer_names = {item["function"]["name"] for item in reviewer} + self.assertIn("submit_requirements_spec", author_names) + self.assertNotIn("review_requirements", author_names | reviewer_names) + self.assertNotIn("get_cdsl_operation_contract", author_names) + self.assertEqual(reviewer_names, {"observe_images", "review_candidate", "review_final"}) + + def test_capability_cache_is_role_scoped(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - workflow = WorkflowCoordinator(WorkflowConfig(1, 1), repository, artifacts, runtime, None, None, requirements, actions) - task_id = "cad_123456abcdef" - initial = repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") + class Models: + def __init__(self) -> None: + self.calls = 0 + async def conformance(self, **_kwargs: object) -> dict[str, object]: + self.calls += 1 + return {"supported": True, "failures": [], "probe_unavailable": False} + models = Models() + first = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="author")) + second = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="author")) + reviewer = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="reviewer")) + self.assertFalse(first.get("cached", False)) + self.assertTrue(second["cached"]) + self.assertNotEqual(first["schema_hash"], reviewer["schema_hash"]) + self.assertEqual(models.calls, 2) + self.assertIsNotNone(cached_model_capability(repository, runtime, provider_id="p", model_id="m", role="author")) - waiting = transition(initial, "waiting_retry", error=ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE) - self.assertEqual(waiting.retry_from_phase, TaskPhase.DRAFTING_REQUIREMENTS) - self.assertTrue(repository.compare_and_swap(waiting)) - self.assertTrue(workflow.resume(task_id)) - resumed = repository.get_state(task_id) - self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS) - self.assertIsNone(resumed.retry_from_phase) - - reviewing = transition(resumed, "requirements_finalized") - self.assertTrue(repository.compare_and_swap(reviewing)) - waiting_review = transition(reviewing, "waiting_retry", error=ErrorCode.REVIEW_SERVICE_UNAVAILABLE) - self.assertEqual(retry_resume_event(waiting_review), "resume_reviewing_requirements") - self.assertTrue(repository.compare_and_swap(waiting_review)) - self.assertTrue(workflow.resume(task_id)) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.REVIEWING_REQUIREMENTS) - - def test_waiting_retry_without_a_persisted_source_is_not_guessed(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.WAITING_RETRY, 7) - self.assertIsNone(retry_resume_event(state)) - - def test_waiting_for_user_cannot_be_resumed_without_new_user_input(self) -> None: + def test_unsupported_capability_is_cached_but_transport_failure_is_not(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - workflow = WorkflowCoordinator(WorkflowConfig(1, 1), repository, artifacts, runtime, None, None, requirements, actions) - task_id = "cad_123456abcdef" - initial = repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - reviewing = transition(initial, "requirements_finalized") - self.assertTrue(repository.compare_and_swap(reviewing)) - waiting = transition(reviewing, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER) - self.assertTrue(repository.compare_and_swap(waiting)) - self.assertFalse(workflow.resume(task_id)) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.WAITING_FOR_USER) + schema_hash = conformance_hash(conformance_tools(runtime, role="reviewer"), role="reviewer") + class Unsupported: + async def conformance(self, **_kwargs: object) -> dict[str, object]: + return {"supported": False, "failures": [{"message": "schema"}], "probe_unavailable": False} + asyncio.run(verify_model_capability(repository, runtime, Unsupported(), provider_id="p", model_id="m", role="reviewer")) + self.assertIsNotNone(repository.model_capability("p", "m", schema_hash)) + class Unavailable: + async def conformance(self, **_kwargs: object) -> dict[str, object]: + return {"supported": False, "failures": [{"message": "network"}], "probe_unavailable": True} + asyncio.run(verify_model_capability(repository, runtime, Unavailable(), provider_id="p2", model_id="m", role="reviewer")) + self.assertIsNone(repository.model_capability("p2", "m", schema_hash)) - def test_waiting_for_user_terminal_exposes_persisted_review_question(self) -> None: + def test_task_started_is_emitted_before_capability_work(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - workflow = WorkflowCoordinator(WorkflowConfig(1, 1), repository, artifacts, runtime, None, None, requirements, actions) - task_id = "cad_123456abcdef" - initial = workflow.create_task(task_id, "Create a plate.") - reviewing = transition(initial, "requirements_finalized") - self.assertTrue(repository.compare_and_swap(reviewing)) - question = "Should the spacing be corrected to 45 degrees or should the count change?" - review_path = artifacts.write_requirements_review(task_id, { - "schema_version": "cad.requirements-review.v2", - "decision": "waiting_for_user", - "findings": [{"draft_id": "draft_001", "source_ids": ["src_001"], "finding_type": "ambiguous_conflict", "description": "Count and spacing conflict.", "question": question}], - }, invocation_id="inv_waiting") - waiting = transition( - reviewing, - "waiting_for_user", - error=ErrorCode.WAITING_FOR_USER, - requirements_review_path=review_path, - ) - self.assertTrue(repository.compare_and_swap(waiting)) - - async def collect() -> list[tuple[str, dict[str, object]]]: - return [item async for item in workflow.run(task_id=task_id, author=None, reviewer=None)] # type: ignore[arg-type] - - events = asyncio.run(collect()) - self.assertEqual(events, [("task_terminal", { - "taskId": task_id, - "lifecycle": "waiting_for_user", - "revisionId": "", - "code": ErrorCode.WAITING_FOR_USER.value, - "message": f"Requirements need a user decision. {question}", - "questions": [question], - "reviewPath": review_path, - "blockerType": "requirements_ambiguity", - "userActionRequired": True, - "issues": ["Count and spacing conflict."], - "unresolved": [{"draftId": "draft_001", "reasonCode": "ambiguous_conflict", "question": question}], - })]) - - def test_requirements_review_limit_is_a_non_user_failure(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - workflow = WorkflowCoordinator(WorkflowConfig(1, 1), repository, artifacts, runtime, None, None, requirements, actions) - task_id = "cad_123456abcdef" - initial = workflow.create_task(task_id, "Create a plate.") - issue = "The requested local position has no available deterministic verifier." - failed = transition(initial, "failed", error=ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED) - self.assertTrue(repository.compare_and_swap(failed, events=[{ - "event": "requirements_review_limit_reached", - "code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value, - "message": "Requirements review did not converge within the configured author-revision limit.", - "findings": [{"description": issue}], - }])) - - payload = workflow._projected_terminal(task_id, failed) - - self.assertEqual(payload["lifecycle"], "failed") - self.assertFalse(payload["userActionRequired"]) - self.assertEqual(payload["issues"], [issue]) - self.assertEqual(payload["code"], ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value) - - def test_waiting_review_limit_reply_resumes_the_same_task_with_clarification(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = service_settings(root) + configured = settings(root) service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - state = service.v3.workflow.create_task(task_id, "Create a flange.") - reviewing = transition(state, "requirements_finalized") - self.assertTrue(service.v3.repository.compare_and_swap(reviewing)) - question = "Should the flange use four holes or six holes?" - review_path = service.v3.artifacts.write_requirements_review(task_id, { - "schema_version": "cad.requirements-review.v2", - "decision": "waiting_for_user", - "findings": [{"draft_id": "draft_001", "source_ids": ["src_001"], "finding_type": "ambiguous_conflict", "description": "Two hole counts are stated.", "question": question}], - }, invocation_id="inv_limit") - waiting = transition(reviewing, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, requirements_review_path=review_path) - self.assertTrue(service.v3.repository.compare_and_swap(waiting, events=[{"event": "requirements_waiting_for_user", "review_path": review_path, "questions": [question]}])) - conversation = service.store.ensure_conversation("conv_123456abcdef", task_id) - message = ChatMessage.model_validate({"id": "user_1", "role": "user", "parts": [{"type": "text", "text": "允许"}]}) - - async def fake_run(*, task_id: str, **_kwargs: object): - self.assertEqual(task_id, "cad_123456abcdef") - yield "task_terminal", {"taskId": task_id, "lifecycle": "waiting_for_user", "message": "Still needs review."} - - service.v3.workflow.run = fake_run # type: ignore[method-assign] - - async def collect() -> bytes: - return b"".join([chunk async for chunk in service.stream([message], conversation["conversation_id"], task_id)]) - - with patch("app.services.agent_service.verify_model_capability", AsyncMock(return_value={"supported": True})): - output = asyncio.run(collect()).decode("utf-8") - self.assertIn("已应用补充说明并恢复 CAD 任务", output) - self.assertNotIn("event: cad_error", output) - self.assertEqual([item.name for item in configured.task_root.iterdir()], [task_id]) - resumed = service.v3.repository.get_state(task_id) - self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS) - clarification = next(event for event in service.v3.repository.ledger_events(task_id) if event.get("event") == "user_clarification_received") - payload = service.v3.artifacts.read_json(task_id, clarification["clarification_path"]) - self.assertEqual(payload["text"], "允许") - - def test_service_retry_resume_checks_capabilities_and_preserves_source_phase(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = service_settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - state = service.v3.workflow.create_task(task_id, "Create a plate.") - reviewing = transition(state, "requirements_finalized") - self.assertTrue(service.v3.repository.compare_and_swap(reviewing)) - waiting = transition(reviewing, "waiting_retry", error=ErrorCode.REVIEW_SERVICE_UNAVAILABLE) - self.assertTrue(service.v3.repository.compare_and_swap(waiting)) - - async def consume_without_provider_calls(*_args: object) -> None: - await asyncio.sleep(0) - - service._consume_discarding = consume_without_provider_calls # type: ignore[method-assign] - capability = AsyncMock(return_value={"supported": True}) - with patch("app.services.agent_service.verify_model_capability", capability): - projection = asyncio.run(service.resume_retry(task_id)) - - self.assertEqual(capability.await_count, 2) - self.assertEqual((projection or {}).get("phase"), TaskPhase.REVIEWING_REQUIREMENTS.value) - resumed = service.v3.repository.get_state(task_id) - self.assertEqual(resumed.phase, TaskPhase.REVIEWING_REQUIREMENTS) - self.assertIsNone(resumed.retry_from_phase) - - def test_service_retry_resume_rejects_waiting_for_user_before_provider_preflight(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = service_settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - state = service.v3.workflow.create_task(task_id, "Create a plate.") - reviewing = transition(state, "requirements_finalized") - self.assertTrue(service.v3.repository.compare_and_swap(reviewing)) - waiting = transition(reviewing, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER) - self.assertTrue(service.v3.repository.compare_and_swap(waiting)) - - capability = AsyncMock(return_value={"supported": True}) - with patch("app.services.agent_service.verify_model_capability", capability): - with self.assertRaisesRegex(ValueError, "Only a WAITING_RETRY"): - asyncio.run(service.resume_retry(task_id)) - + message = ChatMessage.model_validate({"id": "user_1", "role": "user", "parts": [{"type": "text", "text": "Create a plate."}]}) + async def first_chunk() -> bytes: + stream = service.stream([message], None, None) + chunk = await anext(stream) + await stream.aclose() + return chunk + with patch("app.services.agent_service.verify_model_capability", AsyncMock()) as capability: + chunk = asyncio.run(first_chunk()).decode("utf-8") + self.assertIn("task_started", chunk) capability.assert_not_awaited() + self.assertEqual(len(service.v3.repository.running_task_ids()), 1) - def test_service_cancel_persists_terminal_state_and_preserves_checkpoint(self) -> None: + def test_cached_capabilities_skip_normal_request_probe(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - configured = service_settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - initial = service.v3.workflow.create_task(task_id, "Create a plate.") - checkpoint = transition(initial, "requirements_finalized") - self.assertTrue(service.v3.repository.compare_and_swap(checkpoint)) - - projection = asyncio.run(service.cancel(task_id)) - - state = service.v3.repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.CANCELLED) - self.assertEqual(state.last_error, ErrorCode.CANCELLED) - self.assertEqual((projection or {}).get("lifecycle"), "cancelled") - self.assertTrue(any(event.get("event") == "task_cancelled" for event in service.v3.repository.ledger_events(task_id))) - self.assertEqual(service.v3.repository.running_task_ids(), []) - - def test_cancelled_workflow_is_terminal_and_never_reclassified_as_internal_failure(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - workflow = WorkflowCoordinator(WorkflowConfig(1, 1), repository, artifacts, runtime, None, None, requirements, actions) - task_id = "cad_123456abcdef" - initial = workflow.create_task(task_id, "Create a plate.") - cancelled = transition(initial, "cancelled", error=ErrorCode.CANCELLED) - self.assertTrue(repository.compare_and_swap(cancelled)) - - async def collect() -> list[tuple[str, dict[str, object]]]: - return [item async for item in workflow.run(task_id=task_id, author=None, reviewer=None)] # type: ignore[arg-type] - - events = asyncio.run(collect()) - self.assertEqual(events[-1][0], "task_terminal") - self.assertEqual(events[-1][1]["lifecycle"], "cancelled") - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.CANCELLED) - - def test_cancelled_state_rejects_failure_and_retry_transitions(self) -> None: - cancelled = TaskState("cad_123456abcdef", TaskPhase.CANCELLED, 4) - for event in ("failed", "waiting_retry", "cancelled"): - with self.assertRaisesRegex(ValueError, "Illegal v3 transition"): - transition(cancelled, event, error=ErrorCode.CANCELLED) - - def test_task_creation_freezes_document_attachment_as_a_stable_source_block(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = service_settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - conversation = service.store.ensure_conversation("conv_123456abcdef") - document = b"Outer diameter is 120 mm.\nCenter bore is through." - relative_path, _ = service.store.write_conversation_upload(conversation["conversation_id"], "flange.txt", document) - extracted_path = relative_path + ".txt" - service.store.conversation_attachment_path(conversation["conversation_id"], extracted_path).write_text( - document.decode("utf-8"), encoding="utf-8" - ) - attachment = { - "id": "upload_flange", "conversation_id": conversation["conversation_id"], "name": "flange.txt", - "kind": "document", "path": relative_path, "mime": "text/plain", "size": len(document), - "sha256": sha256(document).hexdigest(), "extracted_path": extracted_path, - } - conversation = service.store.add_conversation_attachment(conversation["conversation_id"], attachment) - - task_id = "cad_123456abcdef" - blocks = service._task_source_blocks(conversation, "Create a flange.\n\nUse the attached dimensions.") - service.v3.workflow.create_task(task_id, "Create a flange.\n\nUse the attached dimensions.", source_blocks=blocks) - - source_index = service.v3.artifacts.read_json(task_id, "documents/source-index.json") or {} - self.assertEqual(source_index["sources"]["src_001"], "Create a flange.") - self.assertEqual(source_index["sources"]["src_002"], "Use the attached dimensions.") - self.assertEqual(source_index["sources"]["src_003"], document.decode("utf-8")) - self.assertEqual(source_index["attachment_blocks"]["src_003"]["sha256"], sha256(document).hexdigest()) - self.assertIn(document.decode("utf-8"), service.v3.artifacts.read_source_requirements(task_id)) - - service.store.conversation_attachment_path(conversation["conversation_id"], extracted_path).write_text( - "changed after task creation", encoding="utf-8" - ) - self.assertEqual( - (service.v3.artifacts.read_json(task_id, "documents/source-index.json") or {})["sources"]["src_003"], - document.decode("utf-8"), - ) - - def test_startup_resume_requires_author_and_reviewer_capabilities(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = service_settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - service.v3.workflow.create_task("cad_123456abcdef", "Create a plate.") - capability = AsyncMock(side_effect=[{"supported": True}, {"supported": False}]) - with patch("app.services.agent_service.verify_model_capability", capability): - asyncio.run(service.resume_running_tasks()) - - self.assertEqual(capability.await_count, 2) - self.assertEqual(service._autonomous_runs, {}) - state = service.v3.repository.get_state("cad_123456abcdef") - self.assertEqual(state.phase, TaskPhase.FAILED) - self.assertEqual(state.last_error, ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) - - def test_domain_and_application_do_not_import_adapter_or_io_implementations(self) -> None: - package = ROOT / "backend" / "app" / "cad_agent" - forbidden_modules = { - "sqlite3", "pathlib", "os", "shutil", - "app.cad_agent.adapters", "app.services.engine_service", "app.services.review_renderer", - } - for layer in ("domain", "application"): - for path in (package / layer).glob("*.py"): - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - imported: set[str] = set() - for node in ast.walk(tree): - if isinstance(node, ast.Import): - imported.update(alias.name for alias in node.names) - elif isinstance(node, ast.ImportFrom) and node.module: - imported.add(node.module) - for module in imported: - self.assertFalse( - any(module == forbidden or module.startswith(forbidden + ".") for forbidden in forbidden_modules), - f"{path.relative_to(ROOT)} imports forbidden implementation module {module}", - ) - - def test_dynamic_requirements_schema_rejects_unknown_claim_fields(self) -> None: - schema = requirements_draft_schema(default_registry().expected_one_of_schema(), ["src_001"]) - error = canonical_validate_schema(json.dumps({ - "items": [{ - "source_ids": ["src_001"], "statement": "One solid", "assumptions": [], - "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1, "unexpected": True}}], - }], - }), schema) - self.assertIsNotNone(error) - self.assertEqual(error.code if error else None, ErrorCode.AUTHOR_FORMAT_INVALID) - - def test_requirement_claims_reject_vacuous_geometry_tolerances(self) -> None: - registry = default_registry() - bbox_errors = registry.validate_expected( - "bbox_dimension_mm", - {"axis": "z", "value": 10, "tolerance_mm": 1.01}, - ) - bore_errors = registry.validate_expected( - "through_cylindrical_bore", - {"diameter_mm": 12, "count": 3, "tolerance_mm": 5}, - ) - coaxial_errors = registry.validate_expected( - "coaxial", - {"record_ids": ["topology_a", "topology_b"], "tolerance": 0.1}, - ) - self.assertTrue(any(error["path"] == "/tolerance_mm" for error in bbox_errors)) - self.assertTrue(any(error["path"] == "/tolerance_mm" for error in bore_errors)) - self.assertTrue(any(error["path"] == "/tolerance" for error in coaxial_errors)) - - def test_dynamic_requirements_schema_rejects_unknown_source_before_write(self) -> None: - schema = requirements_draft_schema(default_registry().expected_one_of_schema(), ["src_001"]) - error = canonical_validate_schema(json.dumps({ - "items": [{ - "source_ids": ["src_unknown"], "statement": "One solid", "assumptions": [], - "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}], - }], - }), schema) - self.assertEqual(error.code if error else None, ErrorCode.AUTHOR_FORMAT_INVALID) - - def test_provider_conformance_rejects_tool_arguments_that_violate_schema(self) -> None: - gateway = StructuredModelGateway(settings(Path(tempfile.gettempdir()))) - - async def invalid_response(**_kwargs: object) -> dict[str, object]: - return { - "tool_calls": [{"function": {"name": "probe", "arguments": "{}"}}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1}, - } - - gateway.call_tool = invalid_response # type: ignore[method-assign] - report = asyncio.run(gateway.conformance( - provider_id="test", - model_id="test-model", - tools=[{"type": "function", "function": {"name": "probe", "parameters": {"type": "object", "properties": {"value": {"type": "integer"}}, "required": ["value"], "additionalProperties": False}}}], - )) - self.assertFalse(report["supported"]) - self.assertIn("violate schema", report["failures"][0]["message"]) - self.assertFalse(report["probe_unavailable"]) - - def test_provider_conformance_stops_after_transport_failure(self) -> None: - gateway = StructuredModelGateway(settings(Path(tempfile.gettempdir()))) - calls = 0 - - async def unavailable(**_kwargs: object) -> dict[str, object]: - nonlocal calls - calls += 1 - raise StructuredTransportError("Provider transport unavailable after 3 attempts") - - gateway.call_tool = unavailable # type: ignore[method-assign] - report = asyncio.run(gateway.conformance( - provider_id="test", - model_id="test-model", - tools=[ - {"type": "function", "function": {"name": "probe_one", "parameters": {"type": "object"}}}, - {"type": "function", "function": {"name": "probe_two", "parameters": {"type": "object"}}}, - ], - )) - - self.assertEqual(calls, 1) - self.assertFalse(report["supported"]) - self.assertTrue(report["probe_unavailable"]) - self.assertEqual([failure["tool"] for failure in report["failures"]], ["probe_one"]) - - def test_live_eval_main_persists_unexpected_preflight_failure(self) -> None: - """A failed conformance preflight must still produce a release-gate report.""" - import app.cad_agent.evals.live as live - - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - - async def unexpected(*_args: object, **_kwargs: object) -> dict[str, object]: - raise RuntimeError("conformance bootstrap broke") - - arguments = type("Arguments", (), { - "suite": "smoke", "require_live": True, "allow_skip": False, - })() - with patch.object(live, "BACKEND_ROOT", root), patch.object(live, "_arguments", return_value=arguments), patch.object(live, "_run", unexpected): - self.assertEqual(live.main(), 2) - - reports = list((root / "live-evals").glob("*/report.json")) - self.assertEqual(len(reports), 1) - report = json.loads(reports[0].read_text(encoding="utf-8")) - self.assertEqual(report["status"], "LIVE_EVAL_BLOCKED") - self.assertIn("RuntimeError: conformance bootstrap broke", report["error"]) - self.assertEqual(report["failure_layer"], "configuration_or_network") - - def test_provider_protocol_error_is_not_reclassified_as_author_format_error(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - - class ProviderRejectedGateway: - async def call_tool(self, **_kwargs: object) -> dict[str, object]: - raise StructuredModelError("Provider rejected structured request (400)") - - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=1, format_error_limit=1), repository, artifacts, runtime, - ProviderRejectedGateway(), None, RequirementsCommandHandler(repository, artifacts, default_registry()), - ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())), - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - author = type("Model", (), {"provider_id": "test", "model_id": "test-model"})() - return [item async for item in workflow.run(task_id=task_id, author=author, reviewer=None)] - - events = asyncio.run(run()) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.WAITING_RETRY) - self.assertEqual(state.last_error, ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE) - self.assertEqual(events[-1][1]["code"], ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value) - - def test_runtime_contract_error_fails_once_without_author_format_retry(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - workflow = WorkflowCoordinator(WorkflowConfig(max_turns=4, format_error_limit=4), repository, artifacts, runtime, None, None, requirements, actions) - - terminal = workflow._model_rejection_or_service_failure( - task_id, - repository.get_state(task_id), # type: ignore[arg-type] - "propose_next_action", - WorkflowError(ErrorCode.RUNTIME_CONTRACT_INVALID, "profile registry is invalid"), - {}, - [], - ) - - self.assertIsNotNone(terminal) - self.assertEqual(terminal[1]["code"], ErrorCode.RUNTIME_CONTRACT_INVALID.value) # type: ignore[index] - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.FAILED) - self.assertEqual(state.last_error, ErrorCode.RUNTIME_CONTRACT_INVALID) - self.assertFalse(any(event.get("event") == "failed_author_format" for event in repository.ledger_events(task_id))) - - def test_runtime_precondition_rejection_returns_to_action_selection_without_format_failure(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=4, format_error_limit=1), repository, artifacts, actions.runtime, - None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions, - ) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.ACTION_PENDING) - feedback: list[dict[str, object]] = [] - terminal = workflow._model_rejection_or_service_failure( - task_id, - state, - "submit_cdsl_fragment", - WorkflowError( - ErrorCode.RUNTIME_PRECONDITION_FAILED, - "cut depth 12 mm must exceed measured host-body thickness 12 mm", - ), - {}, - feedback, - ) - after = repository.get_state(task_id) - self.assertIsNone(terminal) - self.assertEqual(after.phase, TaskPhase.AWAITING_ACTION) - self.assertEqual(after.active_revision, state.active_revision) - self.assertIsNone(after.pending_action) - self.assertEqual(after.last_error, ErrorCode.RUNTIME_PRECONDITION_FAILED) - self.assertIn("must exceed measured host-body thickness", str(feedback[0]["content"])) - events = repository.ledger_events(task_id) - self.assertTrue(any(event.get("event") == "runtime_precondition_rejected" for event in events)) - self.assertFalse(any(event.get("event") == "failed_author_format" for event in events)) - - def test_repeated_exact_fragment_is_rejected_without_creating_a_stage(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), repository, artifacts, actions.runtime, - None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions, - ) - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "analytic_contours", "contours": [{ - "role": "outer", "closed": True, - "segments": [{"type": "arc", "start": [1, 0], "end": [0, 1], "center": [0, 0], "radius_mm": 2}], - }]}, - }, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}}, - } - first_state = repository.get_state(task_id) - first = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment_1") - self.assertIsInstance(first, Rejected) - self.assertEqual(first.error.code, ErrorCode.RUNTIME_PRECONDITION_FAILED) - self.assertIsNone(workflow._model_rejection_or_service_failure( - task_id, first_state, "submit_cdsl_fragment", first.error, {}, [], - )) - selection_state = repository.get_state(task_id) - proposal = NextAction( - working_head=selection_state.working_head, intent="Try a corrected base", requirement_ids=["req_001"], - atomic_id="extrude_add_blind", expected_change="Create the base solid", - ) - self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="proposal_2"), Accepted) - - with patch.object(artifacts, "start_candidate_stage") as start_stage: - duplicate = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment_2") - - self.assertIsInstance(duplicate, Rejected) - self.assertTrue(duplicate.error.details["duplicate_fragment"]) - start_stage.assert_not_called() - - def test_third_failure_class_terminates_with_no_progress_limit(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), repository, artifacts, actions.runtime, - None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions, - ) - terminal = None - for attempt in range(3): - state = repository.get_state(task_id) - error = WorkflowError(ErrorCode.RUNTIME_PRECONDITION_FAILED, "profile is degenerate", details={ - "active_revision": state.active_revision, "atomic_id": "extrude_add_blind", - "fragment_hash": f"fragment-{attempt}", "failure_exact_fingerprint": f"exact-{attempt}", - "normalized_error_code": ErrorCode.RUNTIME_PRECONDITION_FAILED.value, - }) - terminal = workflow._model_rejection_or_service_failure( - task_id, state, "submit_cdsl_fragment", error, {}, [], - ) - if attempt < 2: - self.assertIsNone(terminal) - selection = repository.get_state(task_id) - proposal = NextAction( - working_head=selection.working_head, intent=f"Alternative {attempt}", requirement_ids=["req_001"], - atomic_id="extrude_add_blind", expected_change="Create the base solid", - ) - self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id=f"proposal_{attempt}"), Accepted) - - self.assertEqual(terminal[1]["code"], ErrorCode.NO_PROGRESS_LIMIT.value) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FAILED) - - def test_author_call_budget_fails_without_calling_model_and_preserves_checkpoint(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - - class UnexpectedAuthor: - async def call_tool(self, **_kwargs: object) -> dict[str, object]: - raise AssertionError("the author budget must be checked before a provider call") - - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=1, max_author_turns=0), - repository, - artifacts, - actions.runtime, - UnexpectedAuthor(), - None, - RequirementsCommandHandler(repository, artifacts, default_registry()), - actions, - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - identity = ModelIdentity("test", "test-model") - return [item async for item in workflow.run(task_id=task_id, author=identity, reviewer=identity)] - - events = asyncio.run(run()) - after = repository.get_state(task_id) - self.assertEqual(after.phase, TaskPhase.FAILED) - self.assertEqual(after.last_error, ErrorCode.CALL_BUDGET_EXHAUSTED) - self.assertIsNone(after.pending_action) - self.assertEqual(repository.usage_summary(task_id)["calls"], 0) - self.assertEqual(events[-1][1]["code"], ErrorCode.CALL_BUDGET_EXHAUSTED.value) - self.assertFalse(events[-1][1]["userActionRequired"]) - self.assertFalse(any(name in {"action_selection", "candidate_result"} for name, _payload in events)) - budget_event = next(event for event in repository.ledger_events(task_id) if event.get("event") == "call_budget_exhausted") - self.assertEqual(budget_event["next_actor"], "author") - self.assertEqual(budget_event["author_calls"], 0) - - def test_reviewer_call_budget_fails_and_preserves_candidate_stage(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - stage = artifacts.start_candidate_stage(task_id, "budget-stage-key", { - "candidate_id": "candidate_123456789012", - "action_id": action.action_id, - }) - artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", { - "candidate_id": "candidate_123456789012", - "stage_id": stage.stage_id, - "action_id": action.action_id, - "claim_results": [{ - "claim_id": "claim_001", - "claim_kind": "solid_count_equals", - "deterministic": True, - "status": "pass", - }], - }) - building = transition( - state, - "candidate_started", - candidate_id="candidate_123456789012", - candidate_stage_id=stage.stage_id, - ) - reviewing = transition( - building, - "candidate_built", - candidate_id="candidate_123456789012", - candidate_stage_id=stage.stage_id, - ) - self.assertTrue(repository.compare_and_swap(building)) - self.assertTrue(repository.compare_and_swap(reviewing)) - - class UnexpectedReviewer: - async def review(self, **_kwargs: object) -> dict[str, object]: - raise AssertionError("the reviewer budget must be checked before a provider call") - - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=1, max_reviewer_turns=0), - repository, - artifacts, - actions.runtime, - None, - UnexpectedReviewer(), - RequirementsCommandHandler(repository, artifacts, default_registry()), - actions, - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - identity = ModelIdentity("test", "test-model") - return [item async for item in workflow.run(task_id=task_id, author=identity, reviewer=identity)] - - events = asyncio.run(run()) - after = repository.get_state(task_id) - self.assertEqual(after.phase, TaskPhase.FAILED) - self.assertEqual(after.last_error, ErrorCode.CALL_BUDGET_EXHAUSTED) - self.assertEqual(after.candidate_stage_id, "") - self.assertTrue(Path(stage.output_dir).is_dir()) - self.assertEqual(repository.usage_summary(task_id)["calls"], 0) - self.assertEqual(events[-1][1]["code"], ErrorCode.CALL_BUDGET_EXHAUSTED.value) - self.assertFalse(events[-1][1]["userActionRequired"]) - budget_event = next(event for event in repository.ledger_events(task_id) if event.get("event") == "call_budget_exhausted") - self.assertEqual(budget_event["next_actor"], "reviewer") - self.assertEqual(budget_event["reviewer_calls"], 0) - - def test_unexpected_author_gateway_error_fails_once_as_internal(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - - class BrokenGateway: - async def call_tool(self, **_kwargs: object) -> dict[str, object]: - raise RuntimeError("unexpected adapter bug") - - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=4, format_error_limit=4), repository, artifacts, runtime, - BrokenGateway(), None, RequirementsCommandHandler(repository, artifacts, default_registry()), - ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())), - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - author = type("Model", (), {"provider_id": "test", "model_id": "test-model"})() - return [item async for item in workflow.run(task_id=task_id, author=author, reviewer=None)] - - events = asyncio.run(run()) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.FAILED) - self.assertEqual(state.last_error, ErrorCode.FAILED_INTERNAL) - self.assertEqual(events[-1][1]["code"], ErrorCode.FAILED_INTERNAL.value) - self.assertFalse(any(event.get("event") == "failed_author_format" for event in repository.ledger_events(task_id))) - - def test_responses_gateway_normalizes_input_tools_and_function_output(self) -> None: - provider = ProviderConfig("openai", "OpenAI", "https://example.invalid/v1", "test-key", (ProviderModel("test-model", vision=True),), api_style="responses") - gateway = StructuredModelGateway(settings(Path(tempfile.gettempdir()))) - payload = gateway._payload( - provider, - "test-model", - [{"role": "system", "content": "Follow the contract."}, {"role": "user", "content": [{"type": "text", "text": "Review this."}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]}], - [{"type": "function", "function": {"name": "submit", "description": "Submit", "parameters": {"type": "object", "additionalProperties": False}}}], - "submit", - ) - self.assertEqual(payload["input"][0]["role"], "developer") - self.assertEqual(payload["input"][1]["content"][1]["type"], "input_image") - self.assertEqual(payload["tools"][0]["name"], "submit") - self.assertNotIn("strict", payload["tools"][0]) - self.assertEqual(payload["tool_choice"], {"type": "function", "name": "submit"}) - response = gateway._normalized_response(provider, {"output": [{"type": "function_call", "name": "submit", "arguments": "{}"}], "usage": {"input_tokens": 7, "output_tokens": 3}}) - self.assertEqual(response["tool_calls"], [{"function": {"name": "submit", "arguments": "{}"}}]) - self.assertEqual(response["usage"]["total_tokens"], 10) - - def test_capability_probe_exposes_required_selector_as_author_input(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - parameters = fragment_schema(runtime.operation_contract("hole_counterbore"), selector_tokens=["sel_conformance"]) - gateway = StructuredModelGateway(settings(Path(tempfile.gettempdir()))) - messages = gateway._conformance_messages({ - "type": "function", - "function": {"name": "conformance_hole_counterbore", "parameters": parameters}, - }) - instruction = str(messages[0]["content"]) - selector = parameters["properties"]["feature"]["properties"]["selector_tokens"] - self.assertIn("Required author input", str(selector["description"])) - self.assertIn("sel_conformance", instruction) - self.assertIn('"selector_tokens":["sel_conformance"]', instruction) - self.assertIn("must not be moved into params", instruction) - - def test_capability_probe_names_required_candidate_review_decision(self) -> None: - gateway = StructuredModelGateway(settings(Path(tempfile.gettempdir()))) - messages = gateway._conformance_messages({ - "type": "function", - "function": { - "name": "review_candidate", - "parameters": candidate_review_schema("candidate_conformance", "cad_conformance:root:v1", ["claim_001"]), - }, - }) - instruction = str(messages[0]["content"]) - self.assertIn("provide a verdict and claim_coverage", instruction) - self.assertIn("every required top-level and nested field", instruction) - - def test_model_gateway_rejects_any_unnamed_or_multi_tool_request(self) -> None: - gateway = StructuredModelGateway(settings(Path(tempfile.gettempdir()))) - provider = ProviderConfig("openai", "OpenAI", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) - tool = {"type": "function", "function": {"name": "submit", "parameters": {"type": "object", "additionalProperties": False}}} - with self.assertRaisesRegex(StructuredModelError, "exactly one named tool"): - gateway._payload(provider, "test-model", [], [tool], "") - with self.assertRaisesRegex(StructuredModelError, "exactly one named tool"): - gateway._payload(provider, "test-model", [], [tool, tool], "submit") - - def test_provider_conformance_covers_fixed_and_representative_dynamic_contracts(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - tools = conformance_tools(runtime) - names = {str((tool.get("function") or {}).get("name") or "") for tool in tools} - self.assertEqual( - {f"conformance_{atomic_id}" for atomic_id in runtime.supported_atomic_ids()}, - {name for name in names if name.startswith("conformance_")}, - ) - self.assertTrue({ - "submit_requirements_draft_batch", "patch_requirements_draft", "finalize_requirements_draft", - "review_requirements", "propose_next_action", "inspect_topology", - "get_cdsl_operation_contract", "review_candidate", "complete_task", "review_final", - "record_geometry_conclusion", "rollback_checkpoint", - }.issubset(names)) - - def test_live_fixture_can_select_one_stable_comprehensive_scenario(self) -> None: - selected = _fixture("comprehensive", "circular_flange_pcd") - self.assertEqual([item["id"] for item in selected], ["circular_flange_pcd"]) - with self.assertRaisesRegex(ValueError, "Unknown scenario"): - _fixture("comprehensive", "unknown_scenario") - - def test_comprehensive_fixture_is_bound_to_the_target_document_hash(self) -> None: - fixture_path = ROOT / "backend" / "app" / "cad_agent" / "evals" / "fixtures" / "comprehensive.json" - fixture = json.loads(fixture_path.read_text(encoding="utf-8")) - source = ROOT / str(fixture["source_document"]) - self.assertEqual(fixture["source_document_sha256"], sha256(source.read_bytes()).hexdigest()) - - def test_live_capability_probe_separates_transport_failure_from_schema_unsupported(self) -> None: - self.assertEqual( - _capability_block_reason({"failures": [{"message": "Provider transport unavailable after 3 attempts"}]}), - ("MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network"), - ) - self.assertEqual( - _capability_block_reason({"failures": [{"message": "Provider arguments violate schema: required property"}]}), - ("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision"), - ) - - def test_live_failure_attribution_covers_contract_engine_visual_and_timeout_layers(self) -> None: - def attribution(*, outcome: str, checks: dict[str, bool], code: str = "") -> dict[str, object] | None: - events = [{"payload": {"code": code}}] if code else [] - return _failure_attribution(checks=checks, outcome=outcome, events=events, projection={}) - - self.assertEqual( - attribution(outcome="failed", checks={"expected_terminal_phase": False}, code="RUNTIME_CONTRACT_INVALID")["layer"], - "v3_contract_or_verifier", - ) - self.assertEqual( - attribution(outcome="failed", checks={"expected_terminal_phase": False}, code="CANDIDATE_BUILD_FAILED")["layer"], - "engine_execution", - ) - self.assertEqual( - attribution(outcome="failed", checks={"expected_terminal_phase": False}, code="CANDIDATE_REVIEW_REJECTED")["layer"], - "independent_visual_review", - ) - self.assertEqual( - attribution(outcome="failed", checks={"expected_terminal_phase": False}, code="LIVE_EVAL_TIMEOUT")["layer"], - "configuration_or_network", - ) - self.assertEqual( - attribution(outcome="validation_capability_gap", checks={"acceptance_contract_coverage": False})["reason_code"], - "VALIDATION_CAPABILITY_GAP", - ) - self.assertIsNone(_failure_attribution(checks={"expected_terminal_phase": True}, outcome="passed", events=[], projection={})) - - def test_release_gate_checks_audits_claims_and_immutable_manifest(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - revision = root / "revisions" / "rev_001" - render_dir = revision / "renders" - render_dir.mkdir(parents=True) - evidence = { - "candidate.json": b"{}", - "candidate-review.json": b"{}", - "model.cdsl.json": b"{}", - "model.step": b"step", - "model.glb": b"glb", - "model.topology.json": b"{}", - "rebuild-report.json": b"{}", - "renders/render-manifest.json": b"{}", - "renders/contact-sheet.jpg": b"jpeg", - } - for relative, contents in evidence.items(): - path = revision / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(contents) - (revision / "manifest.json").write_text(json.dumps({ - "files": {relative: sha256(contents).hexdigest() for relative, contents in evidence.items()}, - }), encoding="utf-8") - (root / "requirements-contract.json").write_text("{}", encoding="utf-8") - final_review = root / "reviews" / "final" / "rev_001" / "final-review.json" - final_review.parent.mkdir(parents=True) - final_review.write_text("{}", encoding="utf-8") - scenario = { - "expected_phase": "COMPLETED", "max_total_tokens": 10, "max_total_calls": 2, - "max_author_turns": 1, "max_reviewer_turns": 1, - "required_claim_kinds": ["solid_count_equals"], "required_atomic_ids": ["extrude_add_blind"], - } - digest = sha256(b"arguments").hexdigest() - (root / "actions").mkdir() - (root / "actions" / "action-ledger.jsonl").write_text('{"sequence":1}\n', encoding="utf-8") - ledger = [ - {"event": "accepted", "revision_id": "rev_001", "actual_atomic_id": "extrude_add_blind"}, - {"event": "completed", "claim_results": [{"claim_id": "claim_001", "deterministic": True, "status": "pass"}]}, - ] - projection = {"phase": "COMPLETED", "active_revision": "rev_001", "action_ledger_summary": ledger} - usage = {"prompt_tokens": 3, "completion_tokens": 2, "records": [{"raw_arguments_hash": digest}, {"role": "reviewer", "raw_arguments_hash": digest}]} - contract = {"requirements": [{"acceptance_claims": [{"claim_kind": "solid_count_equals"}]}]} - self.assertTrue(all(_run_checks(scenario, projection, usage, contract, [], root, ledger).values())) - semantic_alternative = { - **scenario, - "required_atomic_ids": [], - "required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]], - } - alternative_ledger = [ - {"event": "accepted", "revision_id": "rev_001", "actual_atomic_id": "revolve_add"}, - {"event": "completed", "claim_results": [{"claim_id": "claim_001", "deterministic": True, "status": "pass"}]}, - ] - alternative_checks = _run_checks(semantic_alternative, projection, usage, contract, [], root, alternative_ledger) - self.assertTrue(alternative_checks["required_operations"]) - self.assertTrue(alternative_checks["required_operation_alternative"]) - audits = [{ - "raw_arguments_hash": digest, - "canonical_schema_valid": True, - "single_allowed_call": True, - "state_binding": {"phase": "AWAITING_ACTION", "binding_valid": True}, - }] - self.assertTrue(_run_checks(scenario, projection, usage, contract, [], root, ledger, audits)["raw_argument_audit"]) - audits[0]["state_binding"]["binding_valid"] = False - self.assertFalse(_run_checks(scenario, projection, usage, contract, [], root, ledger, audits)["raw_argument_audit"]) - self.assertFalse(_run_checks(scenario, projection, usage, contract, [{"payload": {"result": {"code": "AUTHOR_FORMAT_INVALID"}}}], root, ledger)["no_schema_or_decision_rejections"]) - (revision / "model.glb").unlink() - self.assertFalse(_run_checks(scenario, projection, usage, contract, [], root, ledger)["required_artifact_evidence"]) - - def test_comprehensive_prompt_fixture_covers_all_document_cases_and_declares_gaps(self) -> None: - scenarios = _fixture("comprehensive") - supported_claim_kinds = set(default_registry().claim_kinds) - supported_atomic_ids = set(ProfileCadRuntime(settings(Path(tempfile.gettempdir()))).supported_atomic_ids()) - source_document = ROOT / "docs" / "cad-agent-v3-comprehensive-prompt-test-target.md" - document_requests = [ - columns[3].strip() - for line in source_document.read_text(encoding="utf-8").splitlines() - if line.startswith("| ") - for columns in [line.split("|")] - if len(columns) >= 5 and columns[1].strip().isdigit() - ] - self.assertEqual(len(scenarios), 20) - self.assertEqual([str(scenario["request"]) for scenario in scenarios], document_requests) - self.assertEqual( - [scenario["id"] for scenario in scenarios], - [ - "rectangular_mounting_plate", "circular_flange_pcd", "square_flange", "counterbored_mounting_plate", - "countersunk_cover_plate", "obround_slot_plate", "t_slot_test_block", "rounded_rectangular_pocket", - "two_level_pocket_plate", "cross_drilled_valve_block", "double_hole_linkage_arm", "three_hole_linkage", - "l_bracket", "ribbed_l_bracket", "u_bearing_support", "double_lug_mount", "stepped_shaft", - "keyed_stepped_shaft", "flanged_sleeve", "chamfered_bushing", - ], - ) - for scenario in scenarios: - self.assertEqual(scenario["units"], "mm") - self.assertEqual(scenario["expected_phase"], "COMPLETED") - self.assertTrue(scenario["request"]) - self.assertTrue(scenario["required_claim_kinds"]) - self.assertTrue(scenario["required_claims"]) - self.assertGreaterEqual( - scenario["max_total_calls"], - scenario["max_author_turns"] + scenario["max_reviewer_turns"], - ) - self.assertGreater(scenario["max_wall_seconds"], 0) - self.assertGreater(scenario["max_total_tokens"], 0) - self.assertTrue(set(scenario["required_claim_kinds"]).issubset(supported_claim_kinds)) - self.assertTrue({claim["claim_kind"] for claim in scenario["required_claims"]}.issubset(supported_claim_kinds)) - required_operations = { - str(atomic_id) - for atomic_id in [ - *(scenario.get("required_atomic_ids") or ()), - *(scenario.get("required_any_atomic_ids") or ()), - *( - atomic_id - for group in scenario.get("required_any_atomic_id_groups") or () - if isinstance(group, list) - for atomic_id in group - ), - ] - } - self.assertTrue(required_operations.issubset(supported_atomic_ids)) - for group in scenario.get("required_any_atomic_id_groups") or (): - self.assertIsInstance(group, list) - self.assertTrue(group) - gap_ids = [str(gap.get("id") or "") for gap in scenario["validation_capability_gaps"]] - self.assertEqual(len(gap_ids), len(set(gap_ids)), f"{scenario['id']} repeats a capability gap") - self.assertTrue(all(gap_ids)) - self.assertTrue(all(str(gap.get("description") or "") for gap in scenario["validation_capability_gaps"])) - - linkage = next(scenario for scenario in scenarios if scenario["id"] == "three_hole_linkage") - assessment = _acceptance_coverage( - linkage, - {"requirements": [{"acceptance_claims": linkage["required_claims"]}]}, - ) - self.assertEqual(assessment["missing_claims"], []) - self.assertFalse(assessment["complete"]) - self.assertEqual( - {gap["id"] for gap in assessment["validation_capability_gaps"]}, - {"linkage_outer_circles"}, - ) - wrong_thickness = _acceptance_coverage( - linkage, - {"requirements": [{"acceptance_claims": [ - *[claim for claim in linkage["required_claims"] if claim["claim_kind"] != "bbox_rank_dimension_mm"], - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}}, - ]}]}, - ) - self.assertIn( - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}}, - wrong_thickness["missing_claims"], - ) - - def test_release_fixture_has_oracle_claims_for_each_scenario(self) -> None: - scenarios = _fixture("release") - registry = default_registry() - self.assertEqual([scenario["id"] for scenario in scenarios], [ - "rectangular_plate", "simple_flange", "ribbed_mounting_plate", "selector_finish", - ]) - for scenario in scenarios: - claims = scenario.get("required_claims") - self.assertIsInstance(claims, list) - self.assertTrue(claims, scenario["id"]) - for claim in claims: - self.assertIsInstance(claim, dict) - self.assertEqual(registry.validate_expected(str(claim.get("claim_kind") or ""), claim.get("expected")), []) - - rectangular = scenarios[0] - self.assertEqual( - _acceptance_coverage(rectangular, {"requirements": [{"acceptance_claims": rectangular["required_claims"]}]}), - { - "required_claim_kinds": ["bbox_dimension_mm", "solid_count_equals"], - "covered_claim_kinds": ["bbox_dimension_mm", "solid_count_equals"], - "required_claims": rectangular["required_claims"], - "missing_claims": [], - "validation_capability_gaps": [], - "complete": True, - }, - ) - - def test_ranked_bbox_dimension_is_orientation_independent(self) -> None: - registry = default_registry() - expected = {"rank": "minimum", "value": 10, "tolerance_mm": 0.1} - self.assertEqual( - registry.evaluate("bbox_rank_dimension_mm", expected, {"health": {"bbox_mm": {"dimensions": [140, 10, 32]}}})["status"], - "pass", - ) - self.assertEqual( - registry.evaluate("bbox_rank_dimension_mm", expected, {"health": {"bbox_mm": {"dimensions": [140, 12, 32]}}})["status"], - "fail", - ) - - def test_bore_chain_and_circular_pattern_verifiers_prove_arrangement(self) -> None: - registry = default_registry() - - def cylinder( - record_id: str, - centre: list[float], - radius: float = 6, - axis_origin: list[float] | None = None, - axis_direction: list[float] | None = None, - ) -> dict[str, object]: - geometry: dict[str, object] = { - "surface_type": "cylinder", - "radius_mm": radius, - "center_mm": centre, - "cylinder_role": "inner", - "through": True, - } - if axis_origin is not None: - geometry["axis_origin_mm"] = axis_origin - if axis_direction is not None: - geometry["axis_direction"] = axis_direction - return { - "record_id": record_id, - "geometry": geometry, - } - - chain_facts = {"topology": {"records": [ - cylinder("bore_1", [0, 0, 0]), - cylinder("bore_2", [60, 0, 0]), - cylinder("bore_3", [140, 0, 0]), - ]}} - chain_expected = {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1} - self.assertEqual( - registry.evaluate( - "collinear_through_bore_chain", - chain_expected, - {"topology": {"records": [cylinder("base_outer", [0, 0, 0], 16)]}}, - )["status"], - "pending", - ) - self.assertEqual( - registry.evaluate( - "collinear_through_bore_chain", - chain_expected, - {"topology": {"records": chain_facts["topology"]["records"][:2]}}, - )["status"], - "pending", - ) - forward_chain = registry.evaluate("collinear_through_bore_chain", chain_expected, chain_facts) - self.assertEqual(forward_chain["status"], "pass") - self.assertEqual(forward_chain["evidence"]["expected_orientation"], "forward") - reverse_chain_facts = {"topology": {"records": list(reversed(chain_facts["topology"]["records"]))}} - reversed_chain = registry.evaluate("collinear_through_bore_chain", chain_expected, reverse_chain_facts) - self.assertEqual(reversed_chain["status"], "pass") - self.assertEqual(reversed_chain["evidence"]["adjacent_distances_mm"], [80.0, 60.0]) - self.assertEqual(reversed_chain["evidence"]["expected_orientation"], "reversed") - chain_facts["topology"]["records"][1]["geometry"]["center_mm"] = [60, 0.2, 0] # type: ignore[index] - self.assertEqual(registry.evaluate("collinear_through_bore_chain", chain_expected, chain_facts)["status"], "fail") - - mixed_host_chain_facts = {"topology": {"records": [ - cylinder("bore_top_1", [0, 0, 5], axis_origin=[0, 0, 10], axis_direction=[0, 0, -1]), - cylinder("bore_bottom", [60, 0, 5], axis_origin=[60, 0, 0], axis_direction=[0, 0, 1]), - cylinder("bore_top_2", [140, 0, 5], axis_origin=[140, 0, 10], axis_direction=[0, 0, -1]), - ]}} - mixed_host_chain = registry.evaluate("collinear_through_bore_chain", chain_expected, mixed_host_chain_facts) - self.assertEqual(mixed_host_chain["status"], "pass", mixed_host_chain) - self.assertEqual(mixed_host_chain["evidence"]["adjacent_distances_mm"], [60.0, 80.0]) - - pattern_facts = {"topology": {"records": [ - cylinder("hole_1", [10, 0, 0], 1), - cylinder("hole_2", [0, 10, 0], 1), - cylinder("hole_3", [-10, 0, 0], 1), - cylinder("hole_4", [0, -10, 0], 1), - ]}} - pattern_expected = {"diameter_mm": 2, "count": 4, "pitch_radius_mm": 10, "tolerance_mm": 0.1} - self.assertEqual(registry.evaluate("circular_hole_pattern", pattern_expected, pattern_facts)["status"], "pass") - - pattern_facts["topology"]["records"][1]["geometry"]["center_mm"] = [5, 8.6602540378, 0] # type: ignore[index] - pattern_facts["topology"]["records"][3]["geometry"]["center_mm"] = [-5, -8.6602540378, 0] # type: ignore[index] - self.assertEqual(registry.evaluate("circular_hole_pattern", pattern_expected, pattern_facts)["status"], "fail") - - concentric_facts = {"topology": {"records": [ - *[ - { - **record, - "geometry": { - **record["geometry"], - "axis_origin_mm": record["geometry"]["center_mm"], "axis_direction": [0, 0, 1], - }, - } - for record in [ - cylinder("hole_1", [10, 0, 0], 1), - cylinder("hole_2", [0, 10, 0], 1), - cylinder("hole_3", [-10, 0, 0], 1), - cylinder("hole_4", [0, -10, 0], 1), - ] - ], - cylinder("central_bore", [0, 0, 0], 4), - ]}} - concentric_facts["topology"]["records"][-1]["geometry"].update({"axis_origin_mm": [0, 0, 0], "axis_direction": [0, 0, 1]}) # type: ignore[index] - concentric_expected = {"diameter_mm": 2, "count": 4, "pitch_radius_mm": 10, "concentric_bore_diameter_mm": 8, "tolerance_mm": 0.1} - self.assertEqual(registry.evaluate("circular_hole_pattern", concentric_expected, concentric_facts)["status"], "pass") - for record in concentric_facts["topology"]["records"][:4]: - record["geometry"]["axis_origin_mm"][0] += 1 # type: ignore[index] - self.assertEqual(registry.evaluate("circular_hole_pattern", concentric_expected, concentric_facts)["status"], "fail") - - def test_operation_bore_verifier_counts_from_the_parent_checkpoint(self) -> None: - """Adding a second single-position hole must not be compared to zero.""" - with tempfile.TemporaryDirectory() as temporary: - _repository, _artifacts, actions, _task_id, _action = self._ready_action(Path(temporary)) - contract = actions.runtime.operation_contract("hole_wizard") - action = PendingAction( - "act_005", "cad_123456abcdef:rev_001:v1", "Add another bore", ("req_001",), - "hole_wizard", "Add one 12 mm bore", contract["contract_hash"], "operation-bore-key", - ) - - def bore(record_id: str, x: float, *, through: bool = False) -> dict[str, object]: - return { - "record_id": record_id, - "kind": "face", - "geometry": { - "surface_type": "cylinder", "cylinder_role": "inner", "radius_mm": 6, - "center_mm": [x, 0, 0], "axis_direction": [0, 0, 1], "through": through, - }, - } - - cdsl = {"features": [{"atomic_id": "hole_wizard", "params": {"diameter_mm": 12, "positions": [{"mm": [60, 0, 0]}]}}]} - parent_facts = {"topology": {"records": [bore("bore_1", 0)]}} - rebuilt = {"health": {}, "topology": {"records": [bore("bore_1", 0), bore("bore_2", 60)]}, "report": {}} - results = actions._operation_candidate_results( - action, contract, cdsl, rebuilt, parent_facts=parent_facts, require_through=False, - ) - - self.assertEqual(results[0]["claim_kind"], "cylindrical_bore") - self.assertEqual(results[0]["status"], "pass", results) - self.assertEqual(results[0]["evidence"]["parent_matching_count"], 1) - self.assertEqual(results[0]["evidence"]["expected_increment"], 1) - self.assertEqual(results[0]["evidence"]["expected_total_count"], 2) - - no_delta = actions._operation_candidate_results( - action, contract, cdsl, - {"health": {}, "topology": {"records": [bore("bore_1", 0)]}, "report": {}}, - parent_facts=parent_facts, - require_through=False, - ) - self.assertEqual(no_delta[0]["status"], "pending", no_delta) - - through_contract = actions.runtime.operation_contract("hole_blind") - through_action = PendingAction( - "act_006", "cad_123456abcdef:rev_001:v2", "Add another through bore", ("req_001",), - "hole_blind", "Add one through 12 mm bore", through_contract["contract_hash"], "operation-through-bore-key", - ) - through_cdsl = {"features": [{"atomic_id": "hole_blind", "params": {"diameter_mm": 12, "positions": [{"mm": [60, 0, 0]}]}}]} - through_parent = {"topology": {"records": [bore("through_bore_1", 0, through=True)]}} - through_rebuilt = {"health": {}, "topology": {"records": [bore("through_bore_1", 0, through=True), bore("through_bore_2", 60, through=True)]}, "report": {}} - through_results = actions._operation_candidate_results( - through_action, through_contract, through_cdsl, through_rebuilt, - parent_facts=through_parent, require_through=True, - ) - self.assertTrue(all(item["status"] == "pass" for item in through_results), through_results) - self.assertTrue(all(item["evidence"]["expected_total_count"] == 2 for item in through_results), through_results) - - def test_bore_count_claims_reject_extra_matching_holes_but_defer_incomplete_patterns(self) -> None: - registry = default_registry() - records = [ - { - "record_id": f"bore_{index}", - "geometry": { - "surface_type": "cylinder", "radius_mm": 4, "center_mm": [index * 10, 0, 0], - "cylinder_role": "inner", "through": True, - }, - } - for index in range(5) - ] - expected = {"diameter_mm": 8, "count": 4, "tolerance_mm": 0.1} - self.assertEqual( - registry.evaluate("through_cylindrical_bore", expected, {"topology": {"records": records[:3]}})["status"], - "pending", - ) - self.assertEqual( - registry.evaluate("through_cylindrical_bore", expected, {"topology": {"records": records}})["status"], - "fail", - ) - - def test_bore_axis_verifiers_prove_coaxiality_and_orthogonal_intersection(self) -> None: - registry = default_registry() - - def bore(record_id: str, diameter: float, origin: list[float], direction: list[float]) -> dict[str, object]: - return { - "record_id": record_id, - "geometry": { - "surface_type": "cylinder", "radius_mm": diameter / 2, - "axis_origin_mm": origin, "axis_direction": direction, - "cylinder_role": "inner", "through": True, - }, - } - - coaxial = [ - bore("lug_a", 16, [0, 0, 0], [1, 0, 0]), - bore("lug_b", 16, [20, 0, 0], [1, 0, 0]), - ] - coaxial_expected = {"diameter_mm": 16, "count": 2, "tolerance_mm": 0.01} - self.assertEqual( - registry.evaluate("coaxial_through_bore_group", coaxial_expected, {"topology": {"records": coaxial}})["status"], - "pass", - ) - coaxial[1]["geometry"]["axis_origin_mm"] = [20, 0.02, 0] # type: ignore[index] - self.assertEqual( - registry.evaluate("coaxial_through_bore_group", coaxial_expected, {"topology": {"records": coaxial}})["status"], - "fail", - ) - - crossing = [ - bore("longitudinal", 20, [0, 0, 0], [1, 0, 0]), - bore("transverse", 12, [0, 0, 0], [0, 1, 0]), - ] - crossing_expected = {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y", "tolerance_mm": 0.01} - self.assertEqual( - registry.evaluate("orthogonal_intersecting_through_bores", crossing_expected, {"topology": {"records": crossing}})["status"], - "pass", - ) - crossing[1]["geometry"]["axis_origin_mm"] = [0, 0, 0.02] # type: ignore[index] - self.assertEqual( - registry.evaluate("orthogonal_intersecting_through_bores", crossing_expected, {"topology": {"records": crossing}})["status"], - "fail", - ) - crossing[1]["geometry"]["axis_origin_mm"] = [0, 0, 0] # type: ignore[index] - crossing[1]["geometry"]["axis_direction"] = [0, 0, 1] # type: ignore[index] - self.assertEqual( - registry.evaluate("orthogonal_intersecting_through_bores", crossing_expected, {"topology": {"records": crossing}})["status"], - "fail", - ) - - def test_subtractive_operation_volume_verifier_rejects_a_noop_cut(self) -> None: - registry = default_registry() - self.assertEqual( - registry.evaluate( - "volume_decreased", - {}, - {"parent_health": {"volume_mm3": 1200}, "health": {"volume_mm3": 1200}}, - )["status"], - "fail", - ) - self.assertEqual( - registry.evaluate( - "volume_decreased", - {}, - {"parent_health": {"volume_mm3": 1200}, "health": {"volume_mm3": 1199.5}}, - )["status"], - "pass", - ) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - runtime = ProfileCadRuntime(settings(root)) - extrude_cut_contract = runtime.operation_contract("extrude_cut_blind") - self.assertIn("volume_decreased", extrude_cut_contract["candidate_verifiers"]) - self.assertIn("volume_decreased", runtime.operation_contract("revolve_cut")["candidate_verifiers"]) - _repository, _artifacts, actions, _task_id, action = self._ready_action(root) - cut_action = replace( - action, - atomic_id="extrude_cut_blind", - contract_hash=str(extrude_cut_contract["contract_hash"]), - ) - results = actions._operation_candidate_results( - cut_action, - extrude_cut_contract, - {"features": [{"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 1}}]}, - {"health": {"volume_mm3": 1200}, "topology": {}, "report": {}}, - parent_facts={"health": {"volume_mm3": 1200}}, - require_through=False, - ) - self.assertEqual( - next(item for item in results if item["claim_kind"] == "volume_decreased")["status"], - "fail", - ) - - def test_live_artifact_manifest_excludes_source_content_and_hashes_cad_evidence(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / "actions").mkdir() - (root / "actions" / "action-ledger.jsonl").write_text('{"event":"accepted"}\n', encoding="utf-8") - (root / "revisions" / "rev_001").mkdir(parents=True) - (root / "revisions" / "rev_001" / "model.cdsl.json").write_text("{}", encoding="utf-8") - (root / "documents").mkdir() - (root / "documents" / "source-index.json").write_text('{"sources":{"src_001":"secret source"}}', encoding="utf-8") - (root / "source-requirements.md").write_text("secret source", encoding="utf-8") - - manifest = _safe_artifact_manifest(root) - - self.assertEqual([item["path"] for item in manifest["files"]], ["actions/action-ledger.jsonl", "revisions/rev_001/model.cdsl.json"]) - self.assertTrue(all(len(item["sha256"]) == 64 for item in manifest["files"])) - - def test_rejected_provider_tool_calls_keep_per_call_audit_evidence(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = service_settings(root) + configured = settings(root) service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) task_id = "cad_123456abcdef" service.v3.workflow.create_task(task_id, "Create a plate.") - service.v3.workflow._record_rejected_tool_calls( - task_id, - actor="author", - expected_tool="submit_requirements_draft_batch", - tool_calls=[ - {"function": {"name": "wrong_tool", "arguments": "{}"}}, - {"function": {"name": "another_wrong_tool", "arguments": "{\"extra\":true}"}}, - ], - schema={"type": "object", "additionalProperties": False}, - ) + queue: asyncio.Queue = asyncio.Queue() + with patch("app.services.agent_service.cached_model_capability", return_value={"supported": True}), patch( + "app.services.agent_service.verify_model_capability", AsyncMock() + ) as verify: + result = asyncio.run(service._ensure_task_capabilities( + task_id, ModelIdentity("author", "author-model"), ModelIdentity("reviewer", "reviewer-model"), queue, + )) + self.assertIsNone(result) + verify.assert_not_awaited() + self.assertTrue(queue.empty()) - audits = service.v3.repository.tool_audits(task_id) - self.assertEqual([audit["returned_tool"] for audit in audits], ["wrong_tool", "another_wrong_tool"]) - self.assertTrue(all(audit["single_allowed_call"] is False for audit in audits)) - self.assertTrue(all(audit["state_binding"]["binding_valid"] is False for audit in audits)) - self.assertEqual(audits[0]["raw_arguments_hash"], sha256(b"{}").hexdigest()) - - def test_token_baseline_requires_matching_provenance_and_proves_median_reduction(self) -> None: - root = Path(tempfile.gettempdir()) - identity = author_request_identity(settings(root).providers[0], settings(root).providers[0].models[0]) - scenarios = [{"id": "plate", "request": "Create a plate."}] - baseline = { - "schema_version": BASELINE_SCHEMA_VERSION, - "protocol_version": "2.0", - "measurement": "prompt_tokens", - "author": identity, - "runtime_profile_sha256": "profile-current", - "scenarios": [{ - "scenario": "plate", "request_sha256": request_sha256("Create a plate."), - "repetitions": [ - {"repetition": 1, "author_metric": 120, "plan_review_metric": 80, "completed": True, "final_review_passed": True, "deterministic_claims_pass": True}, - {"repetition": 2, "author_metric": 130, "plan_review_metric": 70, "completed": True, "final_review_passed": True, "deterministic_claims_pass": True}, - {"repetition": 3, "author_metric": 110, "plan_review_metric": 90, "completed": True, "final_review_passed": True, "deterministic_claims_pass": True}, - ], - }], - } - results = [ - { - "scenario": "plate", "repetition": repetition, - "usage": {"records": [{"prompt_tokens": 100, "usage_available": True}, {"role": "reviewer", "prompt_tokens": 500, "usage_available": True}]}, - "projection": {"phase": "COMPLETED"}, "terminal": {"lifecycle": "completed"}, - "checks": {"deterministic_claims_pass": True}, - } - for repetition in range(1, 4) - ] - comparison = compare_token_baseline( - baseline, scenarios=scenarios, v3_results=results, - author_identity=identity, runtime_profile_hash="profile-current", - ) - self.assertTrue(all(comparison["checks"].values())) - self.assertEqual(comparison["baseline_median_metric"], 200) - self.assertEqual(comparison["v3_median_author_metric"], 100) - self.assertEqual(comparison["median_metric_reduction"], 0.5) - - mismatched = {**baseline, "author": {**identity, "model": "another-model"}} - self.assertTrue(validate_token_baseline_provenance( - mismatched, scenarios=scenarios, author_identity=identity, - runtime_profile_hash="profile-current", - )) - rejected = compare_token_baseline( - mismatched, scenarios=scenarios, v3_results=results, - author_identity=identity, runtime_profile_hash="profile-current", - ) - self.assertFalse(rejected["checks"]["baseline_valid"]) - self.assertFalse(rejected["checks"]["median_metric_reduction"]) - - def test_dynamic_operation_schema_has_exact_shape_and_snapshot_enum(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("hole_blind") - schema = fragment_schema(contract, selector_tokens=["sel_current"]) - self.assertEqual(schema["properties"]["feature"]["properties"]["selector_tokens"]["items"], {"enum": ["sel_current"]}) - self.assertNotIn("sketch", schema["properties"]) - invalid = { - "sketch": {"workplane": {}, "profile": {}}, - "feature": {"atomic_id": "hole_blind", "selector_tokens": ["sel_old"], "params": {"diameter_mm": 10, "depth_mm": 4, "positions": [{"mm": [0, 0, 0]}]}}, - } - self.assertTrue(validate_fragment(contract, invalid, selector_tokens=["sel_current"])) - - def test_runtime_materialization_revalidates_current_contract_and_selector_bounds(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("hole_blind") - selectors = { - "sel_host": { - "kind": "face", - "selector": {"kind": "face", "stable_id": "face_top"}, - "geometry": { - "surface_type": "plane", - "center_mm": [0, 0, 10], - "normal": [0, 0, 1], - "bbox_mm": [-5, -5, 10, 5, 5, 10], - }, - }, - } - fragment = { - "feature": { - "atomic_id": "hole_blind", - "selector_tokens": [], - "params": {"diameter_mm": 2, "depth_mm": 4, "positions": [{"mm": [0, 0, 10]}]}, - }, - } - with self.assertRaisesRegex(RuntimeAdapterError, "active operation schema"): - runtime.materialize_fragment(None, fragment, contract, selectors, {}) - - stale = dict(contract) - stale["contract_hash"] = "stale" - with self.assertRaisesRegex(RuntimeAdapterError, "current verified registry entry"): - runtime.materialize_fragment(None, {**fragment, "feature": {**fragment["feature"], "selector_tokens": ["sel_host"]}}, stale, selectors, {}) - - def test_selector_bound_hole_materializes_a_replay_stable_host_frame(self) -> None: - """A patterned hole must not resolve its source face after that cut splits it.""" + def test_missing_cache_is_visible_and_auto_resumes_same_task(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - runtime = ProfileCadRuntime(settings(root)) - base = { - "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "patterned-hole", - "geometry": {"sketches": [{ - "id": "sketch_001", - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "polygon", "vertices": [[-5, -5], [5, -5], [5, 5], [-5, 5]]}, - }]}, - "features": [{ - "id": "feature_001", "atomic_id": "extrude_add_blind", "depends_on": [], - "params": {"distance_mm": 10}, "sketch_id": "sketch_001", - }], - } - first = runtime.rebuild(base, str(root / "base"), "cad_123456abcdef", "candidate_base") - tokens = runtime.selector_tokens(first["topology"]) - host_token = next( - token for token, value in tokens.items() - if value["kind"] == "face" - and value["geometry"].get("surface_type") == "plane" - and float(value["geometry"].get("normal", [0, 0, 0])[2]) > 0.9 - ) - hole_fragment = { - "feature": { - "atomic_id": "hole_blind", "selector_tokens": [host_token], - "params": {"diameter_mm": 2, "depth_mm": 12, "positions": [{"mm": [0, 0, 10]}]}, - }, - } - with_hole, _audit = runtime.materialize_fragment( - base, hole_fragment, runtime.operation_contract("hole_blind"), tokens, {}, require_through=True, - ) - host = with_hole["features"][-1]["params"]["host_face"] - self.assertEqual(set(host), {"frame"}) - self.assertAlmostEqual(with_hole["features"][-1]["params"]["positions"][0]["mm"][2], 0.0) - with_hole["features"].append({ - "id": "feature_003", "atomic_id": "pattern_linear", "depends_on": ["feature_002"], - "params": { - "source_feature_ids": ["feature_002"], "direction_1": [1, 0, 0], - "spacing_1_mm": 4, "pattern_count_1": 2, - }, - }) - patterned = runtime.rebuild(with_hole, str(root / "patterned"), "cad_123456abcdef", "candidate_patterned") - self.assertEqual(patterned["health"]["solid_count"], 1) - self.assertAlmostEqual(patterned["health"]["volume_mm3"], 1000 - 2 * math.pi * 10, places=5) + configured = settings(root) + service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) + task_id = "cad_123456abcdef" + service.v3.workflow.create_task(task_id, "Create a plate.") + queue: asyncio.Queue = asyncio.Queue() + with patch("app.services.agent_service.cached_model_capability", return_value=None), patch( + "app.services.agent_service.verify_model_capability", AsyncMock(return_value={"supported": True, "probe_unavailable": False}) + ) as verify: + result = asyncio.run(service._ensure_task_capabilities( + task_id, ModelIdentity("author", "author-model"), ModelIdentity("reviewer", "reviewer-model"), queue, + )) + self.assertIsNone(result) + self.assertEqual(verify.await_count, 2) + self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS) + events = [queue.get_nowait(), queue.get_nowait()] + self.assertEqual([item[1]["status"] for item in events], ["waiting", "success"]) - def test_selector_bound_hole_wizard_materializes_against_engine_schema(self) -> None: - """The author contract must contain every CDSL-required Hole Wizard field.""" + def test_image_bytes_are_frozen_and_sent_to_vision(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - runtime = ProfileCadRuntime(settings(root)) - base = { - "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "wizard-host", - "geometry": {"sketches": [{ - "id": "sketch_001", - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "polygon", "vertices": [[-5, -5], [5, -5], [5, 5], [-5, 5]]}, - }]}, - "features": [{ - "id": "feature_001", "atomic_id": "extrude_add_blind", "depends_on": [], - "params": {"distance_mm": 10}, "sketch_id": "sketch_001", - }], - } - first = runtime.rebuild(base, str(root / "base"), "cad_123456abcdef", "candidate_base") - tokens = runtime.selector_tokens(first["topology"]) - host_token = next( - token for token, value in tokens.items() - if value["kind"] == "face" - and value["geometry"].get("surface_type") == "plane" - and float(value["geometry"].get("normal", [0, 0, 0])[2]) > 0.9 - ) - fragment = { - "feature": { - "atomic_id": "hole_wizard", "selector_tokens": [host_token], - "params": { - "hole_type": "simple", "diameter_mm": 2, "depth_mm": 12, - "end_condition": {"type": "blind", "solidworks_code": 0}, - "positions": [{"mm": [0, 0, 10]}], - }, - }, - } - materialized, _audit = runtime.materialize_fragment( - base, fragment, runtime.operation_contract("hole_wizard"), tokens, {}, require_through=True, - ) - self.assertEqual(materialized["features"][-1]["params"]["end_condition"], {"type": "blind", "solidworks_code": 0}) - - def test_operation_contract_rejects_inconsistent_server_selector_and_reference_bindings(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - selector_mismatch = runtime.operation_contract("hole_blind") - selector_mismatch["server_injected_paths"] = [] - with self.assertRaisesRegex(OperationContractError, "injection paths disagree"): - validate_operation_contract(selector_mismatch) - self.assertEqual( - WorkflowCoordinator._selector_tokens_for_contract( - runtime.operation_contract("hole_blind"), - {"sel_face": {"kind": "face"}, "sel_edge": {"kind": "edge"}}, - ), - ["sel_face"], - ) - - reference_mismatch = runtime.operation_contract("pattern_linear") - reference_mismatch["author_params_schema"]["required"].remove("source_feature_ids") - with self.assertRaisesRegex(OperationContractError, "required author array"): - validate_operation_contract(reference_mismatch) - - def test_author_context_exposes_claim_coverage_and_compact_model_facts_without_source_text(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository, artifacts, actions, task_id, _action = self._ready_action(root) - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - workflow = WorkflowCoordinator(WorkflowConfig(1, 1), repository, artifacts, runtime, None, None, requirements, actions) - - context = workflow._author_context(task_id, []) - payload = json.loads(str(context[1]["content"])) - self.assertEqual(payload["model_summary"], {"revision_id": "", "available": False}) - self.assertEqual(payload["claim_coverage"][0]["claim_id"], "claim_001") - self.assertNotIn("source_requirements", payload) - - def test_author_context_retains_claim_targets_and_measured_bore_facts(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository, artifacts, actions, task_id, action = self._ready_action(root) - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - state = repository.get_state(task_id) - contract = { - "schema_version": "cad.requirements-contract.v1", - "task_id": task_id, - "contract_hash": "bore-context-contract", - "requirements": [{ - "requirement_id": "req_001", - "draft_id": "draft_001", - "source_ids": ["src_001"], - "statement": "Three collinear bores", - "assumptions": [], - "acceptance_claims": [{ - "claim_id": "claim_001", - "claim_kind": "through_cylindrical_bore", - "expected": {"diameter_mm": 12, "count": 3}, - }], - }], - } - contract_path = artifacts.write_requirements_contract(task_id, contract, invocation_id="bore_contract") - artifacts.write_json_once(task_id, "revisions/rev_001/rebuild-report.json", { - "health": {"solid_count": 1, "bbox_mm": {"dimensions": [32, 140, 10]}}, - }) - artifacts.write_json_once(task_id, "revisions/rev_001/model.topology.json", { - "snapshot_id": "snapshot_bores", - "records": [{ - "kind": "face", - "geometry": { - "surface_type": "cylinder", - "cylinder_role": "inner", - "radius_mm": 6, - "axis_origin_mm": [0, -70, 10], - "through": True, - }, - }], - }) - state = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id="stage_1234567890abcdef12") - self.assertTrue(repository.compare_and_swap(state)) - state = transition(state, "candidate_built") - self.assertTrue(repository.compare_and_swap(state)) - state = transition( - state, - "candidate_accepted", - active_revision="rev_001", - requirements_contract_path=contract_path, - repair_required=False, - ) - self.assertTrue(repository.compare_and_swap(state)) - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, runtime, None, None, requirements, actions) - - payload = json.loads(workflow._author_context(task_id, [])[1]["content"]) - self.assertEqual( - payload["requirements"][0]["acceptance_claims"], - [{"claim_id": "claim_001", "claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}}], - ) - self.assertEqual( - payload["model_summary"]["inner_cylindrical_bores"], - [{"diameter_mm": 12.0, "axis_origin_mm": [0.0, -70.0, 10.0], "through": True}], - ) - - def test_repair_context_exposes_compact_rejected_candidate_facts(self) -> None: - """Repair turns retain measured failure evidence after staging clears.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository, artifacts, actions, task_id, action = self._ready_action(root) - state = repository.get_state(task_id) - stage = artifacts.start_candidate_stage(task_id, action.idempotency_key, {"schema_version": "test"}) - candidate_id = "candidate_123456789012" - artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", { - "candidate_id": candidate_id, - "actual_atomic_id": "extrude_add_blind", - "health": { - "solid_count": 3, - "feature_count": 1, - "bbox_mm": {"dimensions": [80, 50, 8]}, - }, - "claim_results": [{ - "claim_id": "claim_001", - "claim_kind": "solid_count_equals", - "deterministic": True, - "status": "fail", - "evidence": {"actual": 3, "expected": 1}, - }], - "operation_verifier_results": [{ - "claim_id": "operation_act_004_cylindrical_bore", - "claim_kind": "cylindrical_bore", - "deterministic": True, - "status": "pending", - "evidence": { - "parent_matching_count": 1, - "expected_increment": 1, - "expected_total_count": 2, - }, - }], - }) - artifacts.write_stage_json(task_id, stage.stage_id, "candidate-review.json", { - "evidence": ["render_top: the requested pocket opens at the right edge instead of being centred."], - "issues": ["No material was removed from the centred pocket region."], - }) - building = transition(state, "candidate_started", candidate_id=candidate_id, candidate_stage_id=stage.stage_id) - self.assertTrue(repository.compare_and_swap(building)) - rejected = transition( - building, - "candidate_rejected", - candidate_id="", - candidate_stage_id="", - repair_required=True, - error=ErrorCode.CLAIM_VERIFICATION_FAILED, - ) - self.assertTrue(repository.compare_and_swap(rejected, events=[{ - "event": "candidate_rejected", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - }])) - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, runtime, None, None, requirements, actions) - - payload = json.loads(workflow._author_context(task_id, [])[1]["content"]) - diagnostic = payload["repair_diagnostics"][0] - self.assertEqual(diagnostic["candidate_id"], candidate_id) - self.assertEqual(diagnostic["atomic_id"], "extrude_add_blind") - self.assertEqual(diagnostic["measured"], {"solid_count": 3, "feature_count": 1, "bbox_dimensions_mm": [80, 50, 8]}) - self.assertEqual(diagnostic["failed_claims"], [{ - "claim_id": "claim_001", - "claim_kind": "solid_count_equals", - "status": "fail", - "evidence": {"actual": 3, "expected": 1}, + artifacts = FileArtifactStore(root / "tasks") + task_id = "cad_123456abcdef" + source = root / "reference.png" + png = b"\x89PNG\r\n\x1a\nreference-bytes" + source.write_bytes(png) + artifacts.initialize_task(task_id, "Match the image.", image_inputs=[{ + "path": str(source), "mime": "image/png", "sha256": sha256(png).hexdigest(), }]) - self.assertEqual(diagnostic["operation_blockers"], [{ - "claim_id": "operation_act_004_cylindrical_bore", - "claim_kind": "cylindrical_bore", - "status": "pending", - "evidence": { - "parent_matching_count": 1, - "expected_increment": 1, - "expected_total_count": 2, - }, - }]) - self.assertEqual(diagnostic["review_evidence"], ["render_top: the requested pocket opens at the right edge instead of being centred."]) - self.assertEqual(diagnostic["review_issues"], ["No material was removed from the centred pocket region."]) - self.assertNotIn("fragment", diagnostic) - self.assertIn("fragment_schema", payload["operation_contract"]) - - def test_hole_tool_and_topology_payload_declare_world_space_positions(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - hole_contract = actions.runtime.operation_contract("hole_blind") - hole_action = PendingAction( - action.action_id, - state.working_head, - "Add one bore", - action.requirement_ids, - "hole_blind", - "Add a 12 mm bore", - str(hole_contract["contract_hash"]), - action.idempotency_key, - ) - # Construct the context helper against an immutable pending action; - # this test concerns the generated tool contract wording only. - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions) - state_with_hole = replace(state, pending_action=hole_action) - tools = workflow._action_tools(task_id, state_with_hole, {"contract", "topology"}) - self.assertIn("absolute world-space mm point", tools[0]["function"]["description"]) - self.assertEqual(workflow._topology_payload(task_id, state_with_hole, None, 1)["coordinate_system"], "world_mm") - - def test_canonical_arguments_reject_non_finite_numbers_before_schema_validation(self) -> None: - rejected = canonical_json_object('{"value": NaN}') - self.assertIsInstance(rejected, WorkflowError) - self.assertEqual(rejected.code, ErrorCode.AUTHOR_FORMAT_INVALID) - - def test_hole_position_preflight_requires_selected_host_plane_and_bounds(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("hole_blind") - selectors = { - "sel_host": { - "kind": "face", - "selector": {"kind": "face", "stable_id": "face_top"}, - "geometry": { - "surface_type": "plane", - "center_mm": [0, 0, 10], - "normal": [0, 0, 1], - "bbox_mm": [-5, -5, 10, 5, 5, 10], - }, - }, - } - outside_plane = { - "feature": { - "atomic_id": "hole_blind", - "selector_tokens": ["sel_host"], - "params": {"diameter_mm": 2, "depth_mm": 4, "positions": [{"mm": [0, 0, 9]}]}, - }, - } - with self.assertRaisesRegex(RuntimeAdapterError, "not on the selected host plane"): - runtime.materialize_fragment(None, outside_plane, contract, selectors, {}) - - outside_bounds = { - "feature": { - "atomic_id": "hole_blind", - "selector_tokens": ["sel_host"], - "params": {"diameter_mm": 2, "depth_mm": 4, "positions": [{"mm": [6, 0, 10]}]}, - }, - } - with self.assertRaisesRegex(RuntimeAdapterError, "outside the selected host-face bounds"): - runtime.materialize_fragment(None, outside_bounds, contract, selectors, {}) - - selectors["sel_host"]["geometry"]["boundary_loops_mm"] = [ - [[-5, -5, 10], [5, -5, 10], [5, 5, 10], [-5, 5, 10]], - [[-1, -1, 10], [1, -1, 10], [1, 1, 10], [-1, 1, 10]], - ] - inside_existing_opening = { - "feature": { - "atomic_id": "hole_blind", "selector_tokens": ["sel_host"], - "params": {"diameter_mm": 2, "depth_mm": 4, "positions": [{"mm": [0, 0, 10]}]}, - }, - } - with self.assertRaisesRegex(RuntimeAdapterError, "effective boundary"): - runtime.materialize_fragment(None, inside_existing_opening, contract, selectors, {}) - - def test_analytic_arc_preflight_rejects_off_circle_and_degenerate_arcs(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("extrude_add_blind") - - def fragment(arc: dict[str, object]) -> dict[str, object]: - return { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [arc]}]}, - }, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}}, - } - - with self.assertRaisesRegex(RuntimeAdapterError, "endpoints are not on the declared circle"): - runtime.materialize_fragment(None, fragment({ - "type": "arc", "start": [1, 0], "end": [0, 1], "center": [0, 0], "radius_mm": 2, - }), contract, {}, {}) - with self.assertRaisesRegex(RuntimeAdapterError, "is degenerate"): - runtime.materialize_fragment(None, fragment({ - "type": "arc", "start": [1, 0], "end": [1, 0], "center": [0, 0], "radius_mm": 1, - }), contract, {}, {}) - - def test_hole_materialization_rejects_unusable_host_frame_before_engine(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("hole_blind") - selectors = {"sel_host": { - "kind": "face", "selector": {"kind": "face", "stable_id": "face_bad"}, - "geometry": {"surface_type": "plane", "center_mm": [0, 0, None], "normal": [0, 0, 1], "bbox_mm": [-5, -5, 0, 5, 5, 0]}, - }} - fragment = {"feature": { - "atomic_id": "hole_blind", "selector_tokens": ["sel_host"], - "params": {"diameter_mm": 2, "depth_mm": 4, "positions": [{"mm": [0, 0, 0]}]}, - }} - - with self.assertRaisesRegex(RuntimeAdapterError, "observable planar face"): - runtime.materialize_fragment(None, fragment, contract, selectors, {}) - - def test_revolve_axis_preflight_requires_axis_to_lie_on_sketch_plane(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("revolve_add") - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "circle", "radius_mm": 1}, - }, - "feature": { - "atomic_id": "revolve_add", - "params": {"angle_deg": 180, "axis": {"origin_mm": [0, 0, 1], "direction": [1, 0, 0]}}, - }, - } - with self.assertRaisesRegex(RuntimeAdapterError, "revolve axis is not on the sketch plane"): - runtime.materialize_fragment(None, fragment, contract, {}, {}) - - def test_every_runtime_operation_has_closed_valid_and_invalid_dynamic_fragments(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - for atomic_id in runtime.supported_atomic_ids(): - with self.subTest(atomic_id=atomic_id): - contract = runtime.operation_contract(atomic_id) - params = schema_example(contract["author_params_schema"]) - self.assertIsInstance(params, dict) - if contract["reference_policy"]["mode"] == "snapshot_bound": - slot = str(contract["reference_policy"]["slot"]).removeprefix("params.") - params[slot] = ["ref_current"] - feature = {"atomic_id": atomic_id, "params": params} - if contract["fragment_shape"]["selector_tokens"] == "required": - feature["selector_tokens"] = ["sel_current"] - fragment: dict[str, object] = {"feature": feature} - if contract["fragment_shape"]["sketch"] == "required": - fragment["sketch"] = { - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "circle", "radius_mm": 1}, - } - self.assertEqual(validate_fragment(contract, fragment, selector_tokens=["sel_current"], reference_tokens=["ref_current"]), []) - - malformed = json.loads(json.dumps(fragment)) - malformed["feature"]["atomic_id"] = "wrong_atomic" - self.assertTrue(validate_fragment(contract, malformed, selector_tokens=["sel_current"], reference_tokens=["ref_current"])) - if contract["fragment_shape"]["sketch"] == "required": - missing_sketch = json.loads(json.dumps(fragment)) - del missing_sketch["sketch"] - self.assertTrue(validate_fragment(contract, missing_sketch, selector_tokens=["sel_current"], reference_tokens=["ref_current"])) - else: - extra_sketch = json.loads(json.dumps(fragment)) - extra_sketch["sketch"] = {"workplane": {}, "profile": {}} - self.assertTrue(validate_fragment(contract, extra_sketch, selector_tokens=["sel_current"], reference_tokens=["ref_current"])) - if contract["fragment_shape"]["selector_tokens"] == "required": - stale_selector = json.loads(json.dumps(fragment)) - stale_selector["feature"]["selector_tokens"] = ["sel_stale"] - self.assertTrue(validate_fragment(contract, stale_selector, selector_tokens=["sel_current"], reference_tokens=["ref_current"])) - if contract["reference_policy"]["mode"] == "snapshot_bound": - stale_reference = json.loads(json.dumps(fragment)) - slot = str(contract["reference_policy"]["slot"]).removeprefix("params.") - stale_reference["feature"]["params"][slot] = ["ref_stale"] - self.assertTrue(validate_fragment(contract, stale_reference, selector_tokens=["sel_current"], reference_tokens=["ref_current"])) - - def test_hole_wizard_dynamic_schema_requires_positions_and_variant_details(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("hole_wizard") - schema = fragment_schema(contract, selector_tokens=["sel_host"]) - - missing_positions = { - "feature": { - "atomic_id": "hole_wizard", "selector_tokens": ["sel_host"], - "params": { - "hole_type": "simple", "diameter_mm": 3, "depth_mm": 5, - "end_condition": {"type": "blind", "solidworks_code": 0}, - }, - }, - } - self.assertTrue(validate_fragment(contract, missing_positions, selector_tokens=["sel_host"])) - - missing_end_condition = { - "feature": { - "atomic_id": "hole_wizard", "selector_tokens": ["sel_host"], - "params": { - "hole_type": "simple", "diameter_mm": 3, "depth_mm": 5, - "positions": [{"mm": [0, 0, 0]}], - }, - }, - } - self.assertTrue(validate_fragment(contract, missing_end_condition, selector_tokens=["sel_host"])) - - missing_variant_details = { - "feature": { - "atomic_id": "hole_wizard", "selector_tokens": ["sel_host"], - "params": { - "hole_type": "countersink", "diameter_mm": 3, "depth_mm": 5, - "end_condition": {"type": "blind", "solidworks_code": 0}, - "positions": [{"mm": [0, 0, 0]}], - }, - }, - } - self.assertTrue(validate_fragment(contract, missing_variant_details, selector_tokens=["sel_host"])) - - valid_countersink = { - "feature": { - "atomic_id": "hole_wizard", "selector_tokens": ["sel_host"], - "params": { - "hole_type": "countersink", "diameter_mm": 3, "depth_mm": 5, - "end_condition": {"type": "blind", "solidworks_code": 0}, - "positions": [{"mm": [0, 0, 0]}], - "countersink": {"diameter_mm": 5, "angle_rad": 1.2}, - }, - }, - } - self.assertEqual(validate_fragment(contract, valid_countersink, selector_tokens=["sel_host"]), []) - - def test_materialized_cdsl_schema_drift_is_a_runtime_contract_error(self) -> None: - error = ActionCommandHandler._runtime_error( - ValueError("CDSL schema violation at $.features[1].params: 'end_condition' is a required property") - ) - self.assertEqual(error.code, ErrorCode.RUNTIME_CONTRACT_INVALID) - - def test_materialized_profile_resolution_failure_is_a_recoverable_precondition_error(self) -> None: - error = ActionCommandHandler._runtime_error( - RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: materialized fragment is not executable by the engine: " - "CDSL engine runtime preflight failed: feature feature_001: profile_resolution_failed" - ) - ) - self.assertEqual(error.code, ErrorCode.RUNTIME_PRECONDITION_FAILED) - - def test_runtime_contract_registry_rejects_unknown_preflights_and_exposes_only_registered_verifiers(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - verifier_kinds = set(default_registry().claim_kinds) - declared_preflights: set[str] = set() - for atomic_id in runtime.supported_atomic_ids(): - contract = runtime.operation_contract(atomic_id) - declared_preflights.update(contract["semantic_preflight"]) - self.assertTrue(set(contract["candidate_verifiers"]).issubset(verifier_kinds), atomic_id) - self.assertTrue(declared_preflights.issubset(SEMANTIC_PREFLIGHT_NAMES)) - - invalid = runtime.operation_contract("extrude_add_blind") - invalid["semantic_preflight"] = ["unknown_preflight"] - with self.assertRaisesRegex(OperationContractError, "preflight"): - validate_operation_contract(invalid) - - def test_engine_materializes_metadata_from_the_author_contract_registry(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - from cdsl_engine.operation_contracts import materialized_feature_contracts - - materialized_contracts = materialized_feature_contracts(runtime._profile) - self.assertEqual(set(materialized_contracts), set(runtime.supported_atomic_ids())) - self.assertEqual( - materialized_contracts["hole_blind"]["required_params"], - ["diameter_mm", "depth_mm", "positions", "host_face"], - ) - self.assertTrue(materialized_contracts["extrude_add_blind"]["requires_sketch"]) - - def test_selectorless_extrude_cut_can_preflight_a_through_bore_from_active_body(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - runtime = ProfileCadRuntime(settings(root)) - base = { - "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "cut-proof", - "geometry": {"sketches": [{ - "id": "sketch_001", - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "circle", "radius_mm": 60}, - }]}, - "features": [{ - "id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 12}, - "depends_on": [], "sketch_id": "sketch_001", - }], - } - base_result = runtime.rebuild(base, str(root / "base"), "cad_123456abcdef", "rev_001") - selectors = runtime.selector_tokens(base_result["topology"]) - contract = runtime.operation_contract("extrude_cut_blind") - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 12], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "circle", "radius_mm": 20}, - }, - "feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 14, "reverse": True}}, - } - - cdsl, _audit = runtime.materialize_fragment(base, fragment, contract, selectors, {}, require_through=True) - rebuilt = runtime.rebuild(cdsl, str(root / "through"), "cad_123456abcdef", "candidate_cut") - verdict = default_registry().evaluate( - "through_cylindrical_bore", {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}, - {"topology": rebuilt["topology"]}, - ) - self.assertEqual(verdict["status"], "pass") - - shallow = {**fragment, "feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 12, "reverse": True}}} - with self.assertRaisesRegex(RuntimeAdapterError, "must exceed measured host-body thickness 12"): - runtime.materialize_fragment(base, shallow, contract, selectors, {}, require_through=True) - - def test_selector_bound_fragment_receives_contract_without_ritual_reads(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - contract = actions.runtime.operation_contract("hole_blind") - pending = PendingAction("act_005", state.working_head, "Cut bore", ("req_001",), "hole_blind", "Add through bore", contract["contract_hash"], "hole-key") - selector_state = replace(state, pending_action=pending) - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions) - - initial = {item["function"]["name"] for item in workflow._action_tools(task_id, selector_state, set())} - self.assertEqual(initial, {"submit_cdsl_fragment"}) - after_contract = {item["function"]["name"] for item in workflow._action_tools(task_id, selector_state, {"contract"})} - self.assertEqual(after_contract, {"submit_cdsl_fragment"}) - after_both = {item["function"]["name"] for item in workflow._action_tools(task_id, selector_state, {"topology", "contract"})} - self.assertEqual(after_both, {"submit_cdsl_fragment"}) - - def test_fragment_contract_is_available_immediately_after_action_proposal(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions) - before = {item["function"]["name"] for item in workflow._action_tools(task_id, state, set())} - self.assertEqual(before, {"submit_cdsl_fragment"}) - after = {item["function"]["name"] for item in workflow._action_tools(task_id, state, {"contract"})} - self.assertEqual(after, {"submit_cdsl_fragment"}) - - def test_final_review_schema_binds_current_head_and_claim_ids(self) -> None: - schema = final_review_schema("cad_123456abcdef:rev_001:v8", ["claim_001"]) - rejected = canonical_validate_schema(json.dumps({ - "working_head": "cad_123456abcdef:rev_001:v7", "verdict": "pass", - "claim_coverage": [{"claim_id": "claim_001", "status": "pass"}], - }), schema) - self.assertEqual(rejected.code if rejected else None, ErrorCode.AUTHOR_FORMAT_INVALID) - - def test_observation_schemas_reject_stale_state_before_side_effects(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - self.assertIsNotNone(state) - before_version = state.version - before_head = state.working_head - - stale_topology = canonical_validate_schema(json.dumps({ - "working_head": "cad_123456abcdef:root:v0", "kind": "face", "limit": 16, - }), topology_request_schema(state.working_head)) - wrong_operation = canonical_validate_schema(json.dumps({ - "working_head": state.working_head, "atomic_id": "hole_blind", - }), operation_contract_request_schema(state.working_head, action.atomic_id)) - - self.assertEqual(stale_topology.code if stale_topology else None, ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertEqual(wrong_operation.code if wrong_operation else None, ErrorCode.AUTHOR_FORMAT_INVALID) - after = repository.get_state(task_id) - self.assertEqual(after.version, before_version) - self.assertEqual(after.working_head, before_head) - self.assertEqual(list((artifacts.task_dir(task_id) / ".staging").iterdir()), []) - - def test_geometry_conclusion_is_head_and_evidence_bound_before_state_change(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, _artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - self.assertIsNotNone(state) - schema = geometry_conclusion_schema(state.working_head, list(actions.diagnostic_evidence_refs(task_id, state))) - invalid = canonical_validate_schema(json.dumps({ - "working_head": state.working_head, - "evidence_refs": ["evidence_unknown"], - "root_cause": "The candidate contradicts the action.", - "decision": "return_to_action_selection", - "corrective_intent": "Select a corrected action.", - }), schema) - self.assertEqual(invalid.code if invalid else None, ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertEqual(repository.get_state(task_id).working_head, state.working_head) - - conclusion = GeometryConclusion( - working_head=state.working_head, - evidence_refs=["evidence_current_state"], - root_cause="The candidate contradicts the action.", - decision="return_to_action_selection", - corrective_intent="Select a corrected action.", - ) - result = actions.record_geometry_conclusion(task_id, conclusion, invocation_id="diagnose-return") - self.assertEqual(getattr(result, "payload", {}).get("phase"), TaskPhase.AWAITING_ACTION.value) - after = repository.get_state(task_id) - self.assertIsNotNone(after) - self.assertTrue(after.repair_required) - self.assertIsNone(after.pending_action) - event = repository.ledger_events(task_id)[-1] - self.assertEqual(event["event"], "geometry_conclusion") - self.assertEqual(event["decision"], "return_to_action_selection") - - def test_rollback_requires_current_diagnosis_and_an_ancestor_checkpoint(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, _artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id="stage_1234567890abcdef12") - reviewing = transition(building, "candidate_built") - awaiting = transition(reviewing, "candidate_accepted", active_revision="rev_001") - self.assertTrue(repository.compare_and_swap(building)) - self.assertTrue(repository.compare_and_swap(reviewing)) - self.assertTrue(repository.compare_and_swap(awaiting, events=[{"event": "accepted", "revision_id": "rev_001"}])) - - before_diagnosis = repository.get_state(task_id) - denied = actions.rollback_checkpoint( - task_id, - RollbackCheckpoint(working_head=before_diagnosis.working_head, checkpoint_token="checkpoint_root", reason="Discard the checkpoint."), - invocation_id="rollback-without-diagnosis", - ) - self.assertEqual(getattr(denied, "error", None).code, ErrorCode.AUTHOR_DECISION_REJECTED) - - conclusion = GeometryConclusion( - working_head=before_diagnosis.working_head, - evidence_refs=["evidence_current_model"], - root_cause="The accepted checkpoint has the wrong feature.", - decision="rollback", - corrective_intent="Return to the base checkpoint.", - ) - self.assertIsNotNone(actions.record_geometry_conclusion(task_id, conclusion, invocation_id="diagnose-rollback")) - state = repository.get_state(task_id) - self.assertTrue(actions.rollback_available(task_id, state)) - schema = rollback_checkpoint_schema(state.working_head, list(actions.checkpoint_tokens(task_id, state))) - invalid = canonical_validate_schema(json.dumps({ - "working_head": state.working_head, - "checkpoint_token": "checkpoint_rev_999", - "reason": "Not in lineage.", - }), schema) - self.assertEqual(invalid.code if invalid else None, ErrorCode.AUTHOR_FORMAT_INVALID) - - result = actions.rollback_checkpoint( - task_id, - RollbackCheckpoint(working_head=state.working_head, checkpoint_token="checkpoint_root", reason="Discard the wrong checkpoint."), - invocation_id="rollback-root", - ) - self.assertEqual(getattr(result, "payload", {}).get("status"), "rolled_back") - after = repository.get_state(task_id) - self.assertEqual(after.active_revision, "") - self.assertTrue(after.repair_required) - self.assertEqual(repository.ledger_events(task_id)[-1]["event"], "rollback") - self.assertEqual(actions._next_revision(task_id), "rev_002") - - def test_repair_cannot_select_or_submit_again_before_a_geometry_conclusion(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, _artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id="stage_1234567890abcdef12") - reviewing = transition(building, "candidate_built") - repairing = transition(reviewing, "candidate_rejected", repair_required=True, error=ErrorCode.CANDIDATE_REVIEW_REJECTED) - for value in (building, reviewing, repairing): - self.assertTrue(repository.compare_and_swap(value)) - state = repository.get_state(task_id) - self.assertFalse(actions.repair_action_ready(task_id, state)) - proposal = NextAction( - working_head=state.working_head, - intent="Create a corrected base.", - requirement_ids=["req_001"], - atomic_id="extrude_add_blind", - expected_change="Replace the rejected geometry.", - ) - rejected = actions.propose_next_action(task_id, proposal, invocation_id="repair-before-diagnosis") - self.assertEqual(getattr(rejected, "error", None).code, ErrorCode.AUTHOR_DECISION_REJECTED) - - conclusion = GeometryConclusion( - working_head=state.working_head, - evidence_refs=["evidence_current_state"], - root_cause="The rejected feature needs replacement.", - decision="return_to_action_selection", - corrective_intent="Choose a corrected base action.", - ) - self.assertIsNotNone(actions.record_geometry_conclusion(task_id, conclusion, invocation_id="repair-diagnosis")) - state = repository.get_state(task_id) - self.assertTrue(actions.repair_action_ready(task_id, state)) - accepted = actions.propose_next_action( - task_id, - NextAction( - working_head=state.working_head, - intent="Create a corrected base.", - requirement_ids=["req_001"], - atomic_id="extrude_add_blind", - expected_change="Replace the rejected geometry.", - ), - invocation_id="repair-after-diagnosis", - ) - self.assertEqual(getattr(accepted, "payload", {}).get("atomic_id"), "extrude_add_blind") - pending_state = repository.get_state(task_id) - self.assertTrue(actions.repair_action_ready(task_id, pending_state)) - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, _artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, _artifacts, default_registry()), actions) - self.assertEqual(workflow._recovery_tools(task_id, pending_state), []) - self.assertEqual( - {item["function"]["name"] for item in workflow._action_tools(task_id, pending_state, set())}, - {"submit_cdsl_fragment"}, - ) - - def test_action_availability_hides_required_selector_operations_without_a_snapshot(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, _artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - available = set(actions.available_atomic_ids(task_id, state)) - selector_required = { - atomic_id - for atomic_id in actions.runtime.supported_atomic_ids() - if str((actions.runtime.operation_contract(atomic_id).get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required" - } - - self.assertIn("extrude_add_blind", available) - self.assertTrue(selector_required) - self.assertTrue(selector_required.isdisjoint(available)) - - rejected = actions.propose_next_action( - task_id, - NextAction( - working_head=state.working_head, - intent="Cut a hole before any base exists.", requirement_ids=["req_001"], - atomic_id=next(iter(selector_required)), expected_change="This needs a face selector.", - ), - invocation_id="unavailable-selector-action", - ) - self.assertEqual(getattr(rejected, "error", None).code, ErrorCode.AUTHOR_DECISION_REJECTED) - - def test_action_availability_hides_snapshot_bound_pattern_operations_without_source_features(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, _artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - available = set(actions.available_atomic_ids(task_id, state)) - reference_required = { - atomic_id - for atomic_id in actions.runtime.supported_atomic_ids() - if (actions.runtime.operation_contract(atomic_id).get("reference_policy") or {}).get("mode") == "snapshot_bound" - } - - self.assertIn("pattern_linear", reference_required) - self.assertTrue(reference_required.isdisjoint(available)) - - rejected = actions.propose_next_action( - task_id, - NextAction( - working_head=state.working_head, - intent="Pattern a source feature before any feature exists.", requirement_ids=["req_001"], - atomic_id="pattern_linear", expected_change="This needs a source feature reference.", - ), - invocation_id="unavailable-reference-action", - ) - self.assertEqual(getattr(rejected, "error", None).code, ErrorCode.AUTHOR_DECISION_REJECTED) - - def test_action_availability_hides_operations_requiring_an_active_solid_at_root(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, _artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - available = set(actions.available_atomic_ids(task_id, state)) - active_solid_required = { - atomic_id - for atomic_id in actions.runtime.supported_atomic_ids() - if "requires_active_solid" in (actions.runtime.operation_contract(atomic_id).get("semantic_preflight") or ()) - } - - self.assertEqual( - active_solid_required, - {"extrude_cut_blind", "revolve_cut", "reference_axis", "reference_plane"}, - ) - self.assertTrue(active_solid_required.isdisjoint(available)) - - rejected = actions.propose_next_action( - task_id, - NextAction( - working_head=state.working_head, - intent="Create a reference axis before any solid exists.", requirement_ids=["req_001"], - atomic_id="reference_axis", expected_change="This needs an active solid checkpoint.", - ), - invocation_id="unavailable-root-reference-action", - ) - self.assertEqual(getattr(rejected, "error", None).code, ErrorCode.AUTHOR_DECISION_REJECTED) - - def test_new_candidate_rejection_invalidates_a_prior_repair_conclusion(self) -> None: - """A conclusion may unlock one repair action, never its failed retries.""" - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, first_action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - first_build = transition(state, "candidate_started", candidate_id="candidate_111111111111", candidate_stage_id="stage_111111111111111111") - first_rejected = transition(first_build, "candidate_rejected", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED) - self.assertTrue(repository.compare_and_swap(first_build)) - self.assertTrue(repository.compare_and_swap(first_rejected, events=[{ - "event": "candidate_rejected", "candidate_id": "candidate_111111111111", - "stage_id": "stage_111111111111111111", "action_id": first_action.action_id, - "working_head": first_action.working_head, - }])) - diagnosis_state = repository.get_state(task_id) - conclusion = GeometryConclusion( - working_head=diagnosis_state.working_head, - evidence_refs=["evidence_current_state"], - root_cause="The rejected base needs a different repair action.", - decision="return_to_action_selection", - corrective_intent="Select a corrected base feature.", - ) - self.assertIsInstance(actions.record_geometry_conclusion(task_id, conclusion, invocation_id="first-diagnosis"), Accepted) - ready = repository.get_state(task_id) - self.assertTrue(actions.repair_action_ready(task_id, ready)) - accepted = actions.propose_next_action( - task_id, - NextAction( - working_head=ready.working_head, - intent="Create a corrected base.", requirement_ids=["req_001"], - atomic_id="extrude_add_blind", expected_change="Replace the rejected geometry.", - ), - invocation_id="second-action", - ) - self.assertIsInstance(accepted, Accepted) - second_pending = repository.get_state(task_id) - second_action = second_pending.pending_action - self.assertIsNotNone(second_action) - second_build = transition(second_pending, "candidate_started", candidate_id="candidate_222222222222", candidate_stage_id="stage_222222222222222222") - second_rejected = transition(second_build, "candidate_rejected", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED) - self.assertTrue(repository.compare_and_swap(second_build)) - self.assertTrue(repository.compare_and_swap(second_rejected, events=[{ - "event": "candidate_rejected", "candidate_id": "candidate_222222222222", - "stage_id": "stage_222222222222222222", "action_id": second_action.action_id, - "working_head": second_action.working_head, - }])) - - retrying = repository.get_state(task_id) - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions) - - self.assertFalse(actions.repair_action_ready(task_id, retrying)) - self.assertEqual( - {item["function"]["name"] for item in workflow._recovery_tools(task_id, retrying)}, - {"record_geometry_conclusion"}, - ) - - def test_final_review_rejection_is_bounded_and_receives_diagnostic_feedback(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - artifacts.write_json_once(task_id, "revisions/rev_001/rebuild-report.json", {"health": {"solid_count": 1}}) - artifacts.write_json_once(task_id, "revisions/rev_001/model.topology.json", {"records": []}) - state = repository.get_state(task_id) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id="stage_1234567890abcdef12") - reviewing = transition(building, "candidate_built") - awaiting = transition(reviewing, "candidate_accepted", active_revision="rev_001") - for value in (building, reviewing, awaiting): - self.assertTrue(repository.compare_and_swap(value)) - self.assertIsInstance(actions.complete_task(task_id, invocation_id="complete"), object) - - payloads: list[dict[str, object]] = [] - - class InvalidCoverageGateway: - async def review(self, *, payload: dict[str, object], tool: dict[str, object], **_kwargs: object) -> dict[str, object]: - payloads.append(payload) - parameters = tool["function"]["parameters"] # type: ignore[index] - head = parameters["properties"]["working_head"]["const"] # type: ignore[index] - raw = json.dumps({ - "working_head": head, "verdict": "pass", - "claim_coverage": [ - {"claim_id": "claim_001", "status": "pass"}, - {"claim_id": "claim_001", "status": "pass"}, - ], - }) - return {"tool_calls": [{"function": {"name": "review_final", "arguments": raw}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}} - - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=4, format_error_limit=2), repository, artifacts, actions.runtime, - None, InvalidCoverageGateway(), RequirementsCommandHandler(repository, artifacts, default_registry()), actions, - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - reviewer = type("Model", (), {"provider_id": "test", "model_id": "test-model"})() - return [item async for item in workflow.run(task_id=task_id, author=None, reviewer=reviewer)] - - events = asyncio.run(run()) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FAILED) - self.assertEqual(len(payloads), 2) - self.assertNotIn("previous_schema_or_state_error", payloads[0]) - self.assertIn("previous_schema_or_state_error", payloads[1]) - self.assertEqual(events[-1][1]["code"], ErrorCode.FAILED_AUTHOR_FORMAT.value) - - def test_preflight_rejected_fragment_is_bounded_before_candidate_creation(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - calls = 0 - - class InvalidFragmentGateway: - async def call_tool(self, **_kwargs: object) -> dict[str, object]: - nonlocal calls - calls += 1 - raw = json.dumps({"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": -1}}}) - return {"tool_calls": [{"function": {"name": "submit_cdsl_fragment", "arguments": raw}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}} - - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=4, format_error_limit=2), repository, artifacts, actions.runtime, - InvalidFragmentGateway(), None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions, - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - author = type("Model", (), {"provider_id": "test", "model_id": "test-model"})() - return [item async for item in workflow.run(task_id=task_id, author=author, reviewer=None)] - - events = asyncio.run(run()) - self.assertEqual(calls, 2) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FAILED) - self.assertEqual(list((artifacts.task_dir(task_id) / ".staging").iterdir()), []) - self.assertEqual(events[-1][1]["code"], ErrorCode.FAILED_AUTHOR_FORMAT.value) - - def test_candidate_artifact_io_failure_enters_waiting_retry_without_reclassifying_build(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "circle", "radius_mm": 1}, - }, - "feature": {"atomic_id": action.atomic_id, "params": {"distance_mm": 2}}, - } - with patch.object(actions.runtime, "rebuild", side_effect=OSError("artifact volume unavailable")): - result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment_io") - - self.assertIsInstance(result, Rejected) - self.assertEqual(result.error.code, ErrorCode.STORAGE_FAILURE) - after = repository.get_state(task_id) - self.assertEqual(after.phase, TaskPhase.WAITING_RETRY) - self.assertEqual(after.last_error, ErrorCode.STORAGE_FAILURE) - - def test_candidate_render_failure_enters_render_retry_without_reclassifying_build(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "circle", "radius_mm": 1}, - }, - "feature": {"atomic_id": action.atomic_id, "params": {"distance_mm": 2}}, - } - with patch.object(actions.runtime, "rebuild", side_effect=RuntimeAdapterError("RENDER_SERVICE_UNAVAILABLE: renderer offline")): - result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment_render") - - self.assertIsInstance(result, Rejected) - self.assertEqual(result.error.code, ErrorCode.RENDER_SERVICE_UNAVAILABLE) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.WAITING_RETRY) - self.assertEqual(state.last_error, ErrorCode.RENDER_SERVICE_UNAVAILABLE) - - def test_candidate_stage_creation_failure_enters_waiting_retry_before_build(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, - "profile": {"type": "circle", "radius_mm": 1}, - }, - "feature": {"atomic_id": action.atomic_id, "params": {"distance_mm": 2}}, - } - with patch.object(artifacts, "start_candidate_stage", side_effect=OSError("disk full")): - result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment_stage_io") - - self.assertIsInstance(result, Rejected) - self.assertEqual(result.error.code, ErrorCode.STORAGE_FAILURE) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.WAITING_RETRY) - self.assertEqual(state.retry_from_phase, TaskPhase.ACTION_PENDING) - - def test_requirements_artifact_write_failure_enters_waiting_retry(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - batch = RequirementsDraftBatch.model_validate({"items": [{ - "source_ids": ["src_001"], "statement": "One solid", "assumptions": [], - "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}], - }]}) - with patch.object(artifacts, "write_requirements_draft", side_effect=OSError("disk full")): - result = handler.submit_draft(task_id, batch, invocation_id="draft_io") - - self.assertIsInstance(result, Rejected) - self.assertEqual(result.error.code, ErrorCode.STORAGE_FAILURE) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.WAITING_RETRY) - self.assertEqual(state.retry_from_phase, TaskPhase.DRAFTING_REQUIREMENTS) - - def test_requirements_freeze_is_json_first_and_markdown_is_derived(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - registry = default_registry() - handler = RequirementsCommandHandler(repository, artifacts, registry) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a rectangular base.") - artifacts.initialize_task(task_id, "Create a rectangular base.") - batch = RequirementsDraftBatch.model_validate({"items": [{"source_ids": ["src_001"], "statement": "One connected rectangular base", "assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}]}]}) - self.assertEqual(handler.submit_draft(task_id, batch, invocation_id="draft_1").payload["draft_revision"], 1) - self.assertEqual(handler.finalize_draft(task_id, invocation_id="finalize_1").payload["phase"], TaskPhase.REVIEWING_REQUIREMENTS.value) - review = RequirementsReview.model_validate({"findings": []}) - accepted = handler.record_review(task_id, review, invocation_id="review_1") - self.assertEqual(accepted.payload["phase"], TaskPhase.AWAITING_ACTION.value) - state = repository.get_state(task_id) - self.assertTrue(state.requirements_review_path.startswith("documents/requirements-review-")) - self.assertTrue(state.requirements_contract_path.startswith("documents/requirements-contract-")) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - self.assertEqual(contract["schema_version"], "cad.requirements-contract.v2") - self.assertEqual(contract["requirements"][0]["requirement_id"], "req_001") - self.assertEqual(contract["requirements"][0]["acceptance_claims"][0]["verification_mode"], "deterministic") - self.assertFalse((artifacts.task_dir(task_id) / "requirements.md").exists()) - handler.ensure_rendered_contract_views(task_id, state) - self.assertTrue((artifacts.task_dir(task_id) / "requirements.md").is_file()) - self.assertTrue((artifacts.task_dir(task_id) / "completion.md").is_file()) - - def test_live_evaluation_oracle_revises_reviewer_false_positive_before_freeze(self) -> None: - """Fixture-only claims must block a reviewer pass before CAD begins.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - registry = default_registry() - handler = RequirementsCommandHandler(repository, artifacts, registry) - runtime = ProfileCadRuntime(settings(root)) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry)) - task_id = "cad_123456abcdef" - request = "Create a single connected plate, thickness 10 mm." - repository.create_task(task_id, request) - artifacts.initialize_task(task_id, request) - handler.register_evaluation_contract_oracle(task_id, [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}}, - ]) - incomplete = RequirementsDraftBatch.model_validate({"items": [{ - "source_ids": ["src_001"], "statement": "One solid plate", "assumptions": [], - "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}], - }]}) - reviewer_pass = RequirementsReview.model_validate({"findings": []}) - self.assertIsInstance(handler.submit_draft(task_id, incomplete, invocation_id="draft_1"), Accepted) - self.assertIsInstance(handler.finalize_draft(task_id, invocation_id="finalize_1"), Accepted) - - blocked = handler.record_review(task_id, reviewer_pass, invocation_id="review_1") - - self.assertIsInstance(blocked, Accepted) - self.assertEqual(blocked.payload["phase"], TaskPhase.DRAFTING_REQUIREMENTS.value) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.DRAFTING_REQUIREMENTS) - self.assertEqual(state.requirements_contract_path, "") - oracle = artifacts.read_requirements_review(task_id, state.requirements_review_path) - self.assertEqual(oracle["schema_version"], "cad.requirements-evaluation-oracle.v1") - self.assertEqual(oracle["reviewer_decision"], "freeze") - self.assertEqual(oracle["missing_claims"], [{ - "claim_kind": "bbox_dimension_mm", - "expected": {"axis": "z", "value": 10}, - }]) - reviewer_artifacts = list((artifacts.task_dir(task_id) / "documents").glob("requirements-review-*.json")) - self.assertEqual(len(reviewer_artifacts), 1) - self.assertEqual(json.loads(reviewer_artifacts[0].read_text(encoding="utf-8"))["decision"], "freeze") - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, runtime, None, None, handler, actions) - author_context = json.loads(workflow._author_context(task_id, [])[1]["content"]) - self.assertEqual(author_context["review"]["missing_claims"], oracle["missing_claims"]) - - patched = RequirementsPatchBatch.model_validate({"patches": [{ - "target_draft_id": "draft_001", "op": "replace", "item": { - "source_ids": ["src_001"], "statement": "One solid plate, thickness 10 mm", "assumptions": [], - "acceptance_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.01}}, - ], - }, - }]}) - self.assertIsInstance(handler.patch_draft(task_id, patched, invocation_id="patch_1"), Accepted) - self.assertIsInstance(handler.finalize_draft(task_id, invocation_id="finalize_2"), Accepted) - approved = handler.record_review(task_id, reviewer_pass, invocation_id="review_2") - - self.assertIsInstance(approved, Accepted) - self.assertEqual(approved.payload["phase"], TaskPhase.AWAITING_ACTION.value) - contract = artifacts.read_requirements_contract(task_id, repository.get_state(task_id).requirements_contract_path) - self.assertEqual( - contract["requirements"][0]["acceptance_claims"][1]["expected"], - {"axis": "z", "value": 10, "tolerance_mm": 0.01}, - ) - - def test_production_requirements_review_without_evaluation_oracle_still_freezes(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - batch = RequirementsDraftBatch.model_validate({"items": [{ - "source_ids": ["src_001"], "statement": "One solid", "assumptions": [], - "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}], - }]}) - review = RequirementsReview.model_validate({"findings": []}) - handler.submit_draft(task_id, batch, invocation_id="draft_1") - handler.finalize_draft(task_id, invocation_id="finalize_1") - result = handler.record_review(task_id, review, invocation_id="review_1") - - self.assertIsInstance(result, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.AWAITING_ACTION) - - def test_full_circle_equal_spacing_is_normalized_without_user_confirmation(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - request = "Create eight equally spaced holes around a full circle at 60 degree spacing." - repository.create_task(task_id, request) - artifacts.initialize_task(task_id, request) - batch = RequirementsDraftBatch.model_validate({"items": [{ - "source_ids": ["src_001"], "statement": request, "assumptions": [], - "acceptance_claims": [{"claim_kind": "visual", "expected": {"description": "Eight holes are evenly distributed around the full circle."}}], - }]}) - handler.submit_draft(task_id, batch, invocation_id="draft_1") - handler.finalize_draft(task_id, invocation_id="finalize_1") - review = RequirementsReview.model_validate({"findings": [{ - "draft_id": "draft_001", "source_ids": ["src_001"], - "finding_type": "derivable_conflict", "description": "A full circle with eight equal instances has 45 degree spacing.", - "normalization": {"rule_id": "full_circle_equal_spacing", "count": 8, "declared_spacing_degrees": 60, "full_circle": True}, - }]}) - - result = handler.record_review(task_id, review, invocation_id="review_1") - - self.assertIsInstance(result, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.AWAITING_ACTION) - contract = artifacts.read_requirements_contract(task_id, repository.get_state(task_id).requirements_contract_path) - self.assertEqual(contract["applied_normalizations"][0]["adopted"]["spacing_degrees"], 45.0) - self.assertTrue(any(event.get("event") == "requirements_normalized" for event in repository.ledger_events(task_id))) - - def test_verification_gap_with_visual_claim_freezes_with_risk(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - request = "Place the counterbores on the front side." - repository.create_task(task_id, request) - artifacts.initialize_task(task_id, request) - batch = RequirementsDraftBatch.model_validate({"items": [{ - "source_ids": ["src_001"], "statement": request, "assumptions": [], - "acceptance_claims": [{"claim_kind": "visual", "expected": {"description": "Counterbores are visibly on the front side."}}], - }]}) - handler.submit_draft(task_id, batch, invocation_id="draft_1") - handler.finalize_draft(task_id, invocation_id="finalize_1") - review = RequirementsReview.model_validate({"findings": [{ - "draft_id": "draft_001", "source_ids": ["src_001"], - "finding_type": "verification_gap", "description": "Feature-local front-side placement requires independent visual review.", - }]}) - - result = handler.record_review(task_id, review, invocation_id="review_1") - - self.assertIsInstance(result, Accepted) - contract = artifacts.read_requirements_contract(task_id, repository.get_state(task_id).requirements_contract_path) - self.assertEqual(contract["requirements"][0]["acceptance_claims"][0]["verification_mode"], "visual") - self.assertEqual(len(contract["verification_warnings"]), 1) - projection = repository.get_task_projection(task_id) - self.assertEqual(projection["verification_status"], "completed_with_risks") - - def test_ambiguous_conflict_waits_with_a_precise_question(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Use either four or six holes.") - artifacts.initialize_task(task_id, "Use either four or six holes.") - batch = RequirementsDraftBatch.model_validate({"items": [{ - "source_ids": ["src_001"], "statement": "Use either four or six holes.", "assumptions": [], - "acceptance_claims": [{"claim_kind": "visual", "expected": {"description": "The chosen hole pattern is visible."}}], - }]}) - handler.submit_draft(task_id, batch, invocation_id="draft_1") - handler.finalize_draft(task_id, invocation_id="finalize_1") - question = "Should the part contain four holes or six holes?" - review = RequirementsReview.model_validate({"findings": [{ - "draft_id": "draft_001", "source_ids": ["src_001"], "finding_type": "ambiguous_conflict", - "description": "Two different hole counts are allowed.", "question": question, - }]}) - - result = handler.record_review(task_id, review, invocation_id="review_1") - - self.assertIsInstance(result, Waiting) - self.assertEqual(result.error.details["questions"], [question]) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.WAITING_FOR_USER) - projection = repository.get_task_projection(task_id) - self.assertEqual(projection["message"], "Requirements contain an ambiguity that cannot be resolved deterministically.") - self.assertEqual(projection["questions"], [question]) - self.assertEqual(projection["issues"], ["Two different hole counts are allowed."]) - self.assertTrue(projection["user_action_required"]) - - def test_missing_derived_contract_views_park_for_storage_retry_not_internal_failure(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository, artifacts, actions, task_id, _action = self._ready_action(root) - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - workflow = WorkflowCoordinator(WorkflowConfig(1, 1), repository, artifacts, runtime, None, None, requirements, actions) - - with patch.object(requirements, "ensure_rendered_contract_views", side_effect=OSError("disk full")): - async def run() -> list[tuple[str, dict[str, object]]]: - return [item async for item in workflow.run(task_id=task_id, author=None, reviewer=None)] # type: ignore[arg-type] - - events = asyncio.run(run()) - - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.WAITING_RETRY) - self.assertEqual(state.last_error, ErrorCode.STORAGE_FAILURE) - self.assertEqual(events[-1][1]["code"], ErrorCode.STORAGE_FAILURE.value) - - def test_action_ledger_mirror_failure_parks_for_storage_retry(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository, artifacts, actions, task_id, _action = self._ready_action(root) - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - workflow = WorkflowCoordinator(WorkflowConfig(1, 1), repository, artifacts, runtime, None, None, requirements, actions) - - with patch.object(artifacts, "sync_action_ledger", side_effect=OSError("ledger disk unavailable")): - async def run() -> list[tuple[str, dict[str, object]]]: - return [item async for item in workflow.run(task_id=task_id, author=None, reviewer=None)] # type: ignore[arg-type] - - events = asyncio.run(run()) - - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.WAITING_RETRY) - self.assertEqual(state.last_error, ErrorCode.STORAGE_FAILURE) - self.assertEqual(events[-1][1]["lifecycle"], "waiting_retry") - - def test_requirements_review_coverage_is_derived_from_draft_sources(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - request = "Create a single connected base.\n\nAdd one cylindrical bore." - repository.create_task(task_id, request) - artifacts.initialize_task(task_id, request) - batch = RequirementsDraftBatch.model_validate({"items": [ - {"source_ids": ["src_001"], "statement": "One connected base", "assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}]}, - {"source_ids": ["src_002"], "statement": "One bore", "assumptions": [], "acceptance_claims": [{"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 5}}]}, - ]}) - handler.submit_draft(task_id, batch, invocation_id="draft_1") - handler.finalize_draft(task_id, invocation_id="finalize_1") - review = RequirementsReview.model_validate({"findings": []}) - result = handler.record_review(task_id, review, invocation_id="review_1") - - self.assertIsInstance(result, Accepted) - persisted = artifacts.read_requirements_review(task_id, repository.get_state(task_id).requirements_review_path) - self.assertEqual(persisted["coverage"], [ - {"source_id": "src_001", "requirement_ids": ["req_001"]}, - {"source_id": "src_002", "requirement_ids": ["req_002"]}, - ]) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.AWAITING_ACTION) - - def test_requirements_review_rejects_service_owned_decision_fields(self) -> None: - with self.assertRaisesRegex(ValueError, "Extra inputs are not permitted"): - RequirementsReview.model_validate({ - "verdict": "pass", - "coverage": [{"source_id": "src_001", "requirement_ids": ["req_001"]}], - "item_verdicts": [{"draft_id": "draft_001", "verdict": "pass"}], - "issues": ["Conflicting bore diameter."], - }) - - def test_requirements_invocation_replays_after_the_state_has_advanced(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a rectangular base.") - artifacts.initialize_task(task_id, "Create a rectangular base.") - batch = RequirementsDraftBatch.model_validate({"items": [{"source_ids": ["src_001"], "statement": "One connected base", "assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}]}]}) - - first = handler.submit_draft(task_id, batch, invocation_id="draft_replay") - replay = handler.submit_draft(task_id, batch, invocation_id="draft_replay") - - self.assertEqual(first.payload, replay.payload) - self.assertEqual(repository.get_state(task_id).version, 1) - self.assertEqual(len(artifacts.read_requirements_draft(task_id)["items"]), 1) - records = repository.invocation_records(task_id) - self.assertEqual(len(records), 1) - self.assertEqual(records[0]["status"], "finished") - - def test_requirements_patch_tool_is_hidden_until_reviewer_requests_revision(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - registry = default_registry() - requirements = RequirementsCommandHandler(repository, artifacts, registry) - runtime = ProfileCadRuntime(settings(root)) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry)) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, runtime, None, None, requirements, actions) - draft_schema = requirements.draft_schema(task_id) - patch_schema = requirements.patch_schema(task_id) - initial = {item["function"]["name"] for item in workflow._requirements_author_tools(task_id, draft_schema, patch_schema)} - self.assertEqual(initial, {"submit_requirements_draft_batch"}) - batch = RequirementsDraftBatch.model_validate({"items": [{"source_ids": ["src_001"], "statement": "One solid", "assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}]}]}) - requirements.submit_draft(task_id, batch, invocation_id="draft_1") - finalizable = {item["function"]["name"] for item in workflow._requirements_author_tools(task_id, draft_schema, patch_schema)} - self.assertEqual(finalizable, {"finalize_requirements_draft"}) - requirements.finalize_draft(task_id, invocation_id="finalize_1") - review = RequirementsReview.model_validate({"findings": [{"draft_id": "draft_001", "source_ids": ["src_001"], "finding_type": "missing_source_semantics", "description": "Clarify the requirement."}]}) - requirements.record_review(task_id, review, invocation_id="review_1") - revised = {item["function"]["name"] for item in workflow._requirements_author_tools(task_id, draft_schema, requirements.patch_schema(task_id))} - self.assertEqual(revised, {"patch_requirements_draft"}) - - def test_requirements_patch_context_distinguishes_server_owned_draft_id(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - registry = default_registry() - requirements = RequirementsCommandHandler(repository, artifacts, registry) - runtime = ProfileCadRuntime(settings(root)) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry)) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - batch = RequirementsDraftBatch.model_validate({"items": [{"source_ids": ["src_001"], "statement": "One solid", "assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}]}]}) - requirements.submit_draft(task_id, batch, invocation_id="draft_1") - requirements.finalize_draft(task_id, invocation_id="finalize_1") - requirements.record_review(task_id, RequirementsReview.model_validate({"findings": [{"draft_id": "draft_001", "source_ids": ["src_001"], "finding_type": "claim_mismatch", "description": "Revise dimensions."}]}), invocation_id="review_1") - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, runtime, None, None, requirements, actions) - - context = json.loads(workflow._author_context(task_id, [])[1]["content"]) - instruction = str(context["instruction"]) - schema = requirements.patch_schema(task_id) - patch = schema["$defs"]["RequirementsPatch"] - self.assertIn("target_draft_id", instruction) - self.assertIn('"patches"', instruction) - self.assertIn('"op":"replace"', instruction) - self.assertIn("MUST NOT include draft_id", instruction) - self.assertIn("inside one patches[] entry", patch["properties"]["target_draft_id"]["description"]) - - def test_requirement_patch_recovers_from_the_cad_02df83c6d283_top_level_shape(self) -> None: - """A reviewer-requested patch must be repairable without touching CAD state. - - This is the exact malformed outer shape returned by cad_02df83c6d283: - target_draft_id/op/item were emitted at the tool-call root instead of - inside RequirementsPatchBatch.patches. The next author turn receives - precise feedback and can submit the correctly nested payload. - """ - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - task_id = "cad_02df83c6d283" - request = "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。" - artifacts.initialize_task(task_id, request) - repository.create_task(task_id, request) - - incomplete_item = { - "source_ids": ["src_001"], - "statement": "生成一个连通的三孔机械连杆。", - "assumptions": [], - "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}], - } - revised_item = { - "source_ids": ["src_001"], - "statement": "生成一个三孔机械连杆:孔中心共线,相邻中心距为60 mm和80 mm,孔径12 mm,厚度10 mm,外圆直径32 mm且轮廓平滑连接。", - "assumptions": [], - "acceptance_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.01}}, - {"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3, "tolerance_mm": 0.01}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3, "tolerance_mm": 0.01}}, - {"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.01}}, - ], - } - - class RepairingAuthor: + frozen = Path(artifacts.source_image_paths(task_id)[0]) + class Models: def __init__(self) -> None: - self.patch_messages: list[list[dict[str, object]]] = [] - self.patch_draft_revisions: list[int] = [] - self.calls: list[str] = [] + self.messages: list[list[dict[str, object]]] = [] + async def call_tool(self, *, messages: list[dict[str, object]], **_kwargs: object) -> dict[str, object]: + self.messages.append(messages) + return {"tool_calls": [], "usage": {}} + models = Models() + gateway = RenderedReviewGateway(models) + tool = {"type": "function", "function": {"name": "observe_images", "parameters": {"type": "object"}}} + asyncio.run(gateway.review(kind="image_observation", payload={"reference_image_paths": [str(frozen)]}, tool=tool, provider_id="p", model_id="m")) + image_part = models.messages[0][1]["content"][1] + encoded = image_part["image_url"]["url"].split(",", 1)[1] + self.assertEqual(base64.b64decode(encoded), png) - async def call_tool(self, *, messages: list[dict[str, object]], tool: dict[str, object], required_tool_name: str, **_kwargs: object) -> dict[str, object]: - self.calls.append(required_tool_name) - if required_tool_name == "submit_requirements_draft_batch": - arguments: dict[str, object] = {"items": [incomplete_item]} - elif required_tool_name == "finalize_requirements_draft": - arguments = {} - elif required_tool_name == "patch_requirements_draft": - self.patch_messages.append(messages) - self.patch_draft_revisions.append(int(artifacts.read_requirements_draft(task_id)["revision"])) - arguments = ( - { - "target_draft_id": "draft_001", - "op": "replace", - "reason": "补充可量测的尺寸验收声明。", - "item": revised_item, - } - if len(self.patch_messages) == 1 - else {"patches": [{"target_draft_id": "draft_001", "op": "replace", "item": revised_item}]} - ) - elif required_tool_name == "propose_next_action": - raise StructuredModelError("author transport paused after requirements approval") - else: - raise AssertionError(f"unexpected author tool: {required_tool_name}") - return { - "tool_calls": [{"function": {"name": required_tool_name, "arguments": json.dumps(arguments)}}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - } + def test_final_review_schema_has_ordered_visual_decision_count(self) -> None: + visual = stateless_final_review_schema(2)["properties"]["visual_claims"] + self.assertEqual((visual["minItems"], visual["maxItems"]), (2, 2)) - class RevisingReviewer: - def __init__(self) -> None: - self.calls = 0 - - async def review(self, *, tool: dict[str, object], **_kwargs: object) -> dict[str, object]: - self.calls += 1 - arguments = {"findings": [{ - "draft_id": "draft_001", - "source_ids": ["src_001"], - "finding_type": "missing_source_semantics", - "description": "补充孔距、共线、厚度和外圆尺寸的可量测验收声明。", - }]} if self.calls == 1 else {"findings": []} - return { - "tool_calls": [{"function": {"name": "review_requirements", "arguments": json.dumps(arguments)}}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - } - - author = RepairingAuthor() - reviewer = RevisingReviewer() - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), repository, artifacts, runtime, - author, reviewer, requirements, actions, - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - identity = type("Model", (), {"provider_id": "test", "model_id": "test-model"})() - return [item async for item in workflow.run(task_id=task_id, author=identity, reviewer=identity)] - - events = asyncio.run(run()) - state = repository.get_state(task_id) - self.assertEqual(author.calls, [ - "submit_requirements_draft_batch", "finalize_requirements_draft", - "patch_requirements_draft", "patch_requirements_draft", - "finalize_requirements_draft", "propose_next_action", - ]) - self.assertEqual(reviewer.calls, 2) - # The malformed root object is rejected before the handler sees a - # patch: the retry still observes the original frozen draft. - self.assertEqual(author.patch_draft_revisions, [1, 1]) - self.assertEqual(state.phase, TaskPhase.WAITING_RETRY) - self.assertEqual(state.retry_from_phase, TaskPhase.AWAITING_ACTION) - self.assertTrue(state.requirements_contract_path) - self.assertEqual(artifacts.read_requirements_draft(task_id)["revision"], 2) - feedback = next(message["content"] for message in author.patch_messages[1] if message["role"] == "user" and "schema_or_state_error" in str(message["content"])) - self.assertIn('"path": "/patches"', str(feedback)) - self.assertFalse(any(name == "candidate_result" for name, _payload in events)) - self.assertEqual(list((artifacts.task_dir(task_id) / ".staging").iterdir()), []) - - audits = [item for item in repository.tool_audits(task_id) if item["tool"] == "patch_requirements_draft"] - self.assertEqual(len(audits), 2) - self.assertFalse(audits[0]["canonical_schema_valid"]) - self.assertTrue(audits[1]["canonical_schema_valid"]) - malformed_event = next( - payload for name, payload in events - if name == "tool_call" and payload.get("tool") == "patch_requirements_draft" and payload.get("status") == "error" - ) - self.assertTrue(any(error["path"] == "/patches" for error in malformed_event["result"]["field_errors"])) - self.assertTrue(any(error["path"] == "/reason" for error in malformed_event["result"]["field_errors"])) - - def test_repeated_top_level_requirement_patch_shape_fails_with_field_audit(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - task_id = "cad_123456abcdef" - artifacts.initialize_task(task_id, "Create a plate with a measurable thickness.") - repository.create_task(task_id, "Create a plate with a measurable thickness.") - item = { - "source_ids": ["src_001"], "statement": "One connected plate", "assumptions": [], - "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}], - } - - class RepeatingAuthor: - async def call_tool(self, *, required_tool_name: str, **_kwargs: object) -> dict[str, object]: - if required_tool_name == "submit_requirements_draft_batch": - arguments: dict[str, object] = {"items": [item]} - elif required_tool_name == "finalize_requirements_draft": - arguments = {} - elif required_tool_name == "patch_requirements_draft": - arguments = { - "target_draft_id": "draft_001", - "op": "replace", - "reason": "Add the missing dimension claim.", - "item": item, - } - else: - raise AssertionError(f"unexpected author tool: {required_tool_name}") - return {"tool_calls": [{"function": {"name": required_tool_name, "arguments": json.dumps(arguments)}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}} - - class RevisingReviewer: - async def review(self, **_kwargs: object) -> dict[str, object]: - arguments = {"findings": [{ - "draft_id": "draft_001", "source_ids": ["src_001"], - "finding_type": "missing_source_semantics", "description": "Add the thickness claim.", - }]} - return {"tool_calls": [{"function": {"name": "review_requirements", "arguments": json.dumps(arguments)}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}} - - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=5, format_error_limit=2), repository, artifacts, runtime, - RepeatingAuthor(), RevisingReviewer(), requirements, actions, - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - identity = type("Model", (), {"provider_id": "test", "model_id": "test-model"})() - return [item async for item in workflow.run(task_id=task_id, author=identity, reviewer=identity)] - - events = asyncio.run(run()) - terminal = events[-1][1] - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FAILED) - self.assertEqual(terminal["code"], ErrorCode.FAILED_AUTHOR_FORMAT.value) - self.assertEqual(terminal["tool"], "patch_requirements_draft") - self.assertTrue(any(error["path"] == "/patches" for error in terminal["field_errors"])) - self.assertTrue(any(error["path"] == "/reason" for error in terminal["field_errors"])) - patch_audits = [item for item in repository.tool_audits(task_id) if item["tool"] == "patch_requirements_draft"] - self.assertEqual(len(patch_audits), 2) - self.assertTrue(all(not item["canonical_schema_valid"] for item in patch_audits)) - self.assertEqual(list((artifacts.task_dir(task_id) / ".staging").iterdir()), []) - - def test_candidate_review_payload_exposes_render_evidence_at_the_top_level(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - stage = artifacts.start_candidate_stage(task_id, "candidate-key", {"idempotency_key": "candidate-key", "candidate_id": "candidate_123456789012", "action_id": action.action_id}) - candidate = {"candidate_id": "candidate_123456789012", "stage_id": stage.stage_id, "action_id": action.action_id, "render_manifest": {"contact_sheet_path": "/tmp/contact-sheet.jpg"}, "claim_results": [{"claim_id": "claim_001", "claim_kind": "solid_count_equals", "deterministic": True, "status": "pass"}]} - artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id=stage.stage_id) - review = transition(building, "candidate_built", candidate_id="candidate_123456789012", candidate_stage_id=stage.stage_id) - self.assertTrue(repository.compare_and_swap(building)) - self.assertTrue(repository.compare_and_swap(review)) - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions) - captured: dict[str, object] = {} - - class CapturingGateway: - async def review(self, *, payload: dict[str, object], **_kwargs: object) -> dict[str, object]: - captured.update(payload) - return {"tool_calls": []} - - workflow.review_gateway = CapturingGateway() - result = asyncio.run(workflow._review_candidate(task_id, type("Model", (), {"provider_id": "test", "model_id": "test-model"})(), review)) - self.assertIsInstance(result, WorkflowError) - self.assertEqual(captured["render_manifest"], candidate["render_manifest"]) - self.assertNotIn("source_requirements", captured) - self.assertIn("pending", str(captured["instruction"])) - self.assertIn("MUST NOT cause rejection", str(captured["instruction"])) - - def test_candidate_review_audit_uses_checkpoint_head_bound_by_schema(self) -> None: - """A built candidate is reviewed against its action checkpoint, not v+2 state.""" - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - stage = artifacts.start_candidate_stage(task_id, "candidate-key", { - "idempotency_key": "candidate-key", "candidate_id": "candidate_123456789012", "action_id": action.action_id, - }) - candidate = { - "candidate_id": "candidate_123456789012", "stage_id": stage.stage_id, - "action_id": action.action_id, - "claim_results": [{"claim_id": "claim_001", "claim_kind": "solid_count_equals", "deterministic": True, "status": "pass"}], - } - artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id=stage.stage_id) - reviewing = transition(building, "candidate_built", candidate_id="candidate_123456789012", candidate_stage_id=stage.stage_id) - self.assertTrue(repository.compare_and_swap(building)) - self.assertTrue(repository.compare_and_swap(reviewing)) - self.assertNotEqual(repository.get_state(task_id).working_head, action.working_head) - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions) - - class ValidGateway: - async def review(self, **_kwargs: object) -> dict[str, object]: - arguments = { - "candidate_id": "candidate_123456789012", "working_head": action.working_head, - "verdict": "accept", "claim_coverage": [{"claim_id": "claim_001", "status": "pass"}], - } - return {"tool_calls": [{"function": {"name": "review_candidate", "arguments": json.dumps(arguments)}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}} - - workflow.review_gateway = ValidGateway() - result = asyncio.run(workflow._review_candidate(task_id, type("Model", (), {"provider_id": "test", "model_id": "test-model"})(), reviewing)) - - self.assertIsInstance(result, CandidateReview) - audit = repository.tool_audits(task_id)[-1] - self.assertTrue(audit["canonical_schema_valid"]) - self.assertTrue(audit["state_binding"]["binding_valid"]) - self.assertEqual(audit["state_binding"]["expected_working_head"], action.working_head) - self.assertEqual(audit["state_binding"]["working_head_binding_source"], "schema_const") - - def test_requirements_review_instruction_requires_executable_claim_completeness(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - task_id = "cad_123456abcdef" - request = "Create a single connected flange, diameter 120 mm and thickness 12 mm, with six 8 mm holes on a 90 mm PCD." - artifacts.initialize_task(task_id, request) - repository.create_task(task_id, request) - batch = RequirementsDraftBatch.model_validate({"items": [{ - "source_ids": ["src_001"], "statement": "Single flange", "assumptions": [], - "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}], - }]}) - requirements.submit_draft(task_id, batch, invocation_id="draft_1") - requirements.finalize_draft(task_id, invocation_id="finalize_1") - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, runtime, None, None, requirements, actions) - captured: dict[str, object] = {} - - class CapturingGateway: - async def review(self, *, payload: dict[str, object], **_kwargs: object) -> dict[str, object]: - captured.update(payload) - arguments = {"findings": [{ - "draft_id": "draft_001", "source_ids": ["src_001"], - "finding_type": "missing_source_semantics", "description": "Missing flange dimensions and hole-pattern claims.", - }]} - return {"tool_calls": [{"function": {"name": "review_requirements", "arguments": json.dumps(arguments)}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}} - - workflow.review_gateway = CapturingGateway() - result = asyncio.run(workflow._review_requirements(task_id, type("Model", (), {"provider_id": "test", "model_id": "test-model"})())) - - self.assertIsInstance(result, RequirementsReview) - instruction = str(captured["instruction"]) - self.assertIn("service derives coverage", instruction) - self.assertIn("number, count, dimension, relationship", instruction) - self.assertIn("verification_gap", instruction) - self.assertIn("full-circle equally spaced pattern", instruction) - - def test_live_evaluation_review_context_marks_only_declared_verifier_gaps(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - task_id = "cad_123456abcdef" - request = "Create a plate with a visually smooth outside contour." - repository.create_task(task_id, request) - artifacts.initialize_task(task_id, request) - requirements.register_evaluation_contract_oracle( - task_id, - [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}], - validation_capability_gaps=[{"id": "smooth_outline", "description": "Smooth exterior tangency"}], - ) - batch = RequirementsDraftBatch.model_validate({"items": [{ - "source_ids": ["src_001"], "statement": "One connected smooth plate", "assumptions": [], - "acceptance_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "visual", "expected": {"description": "The outside contour is visibly smooth."}}, - ], - }]}) - requirements.submit_draft(task_id, batch, invocation_id="draft_1") - requirements.finalize_draft(task_id, invocation_id="finalize_1") - workflow = WorkflowCoordinator(WorkflowConfig(8, 2), repository, artifacts, runtime, None, None, requirements, actions) - captured: dict[str, object] = {} - - class CapturingGateway: - async def review(self, *, payload: dict[str, object], **_kwargs: object) -> dict[str, object]: - captured.update(payload) - arguments = {"findings": [{ - "draft_id": "draft_001", "source_ids": ["src_001"], - "finding_type": "verification_gap", "description": "Smooth exterior tangency requires visual review.", - }]} - return {"tool_calls": [{"function": {"name": "review_requirements", "arguments": json.dumps(arguments)}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}} - - workflow.review_gateway = CapturingGateway() - result = asyncio.run(workflow._review_requirements(task_id, type("Model", (), {"provider_id": "test", "model_id": "test-model"})())) - - self.assertIsInstance(result, RequirementsReview) - self.assertEqual(captured["evaluation_context"], { - "evaluation_only": True, - "known_validation_capability_gaps": [{"id": "smooth_outline", "description": "Smooth exterior tangency"}], - }) - self.assertIn("Do not request an invented executable claim", str(captured["instruction"])) - self.assertIn("Do not request an invented executable claim", str(captured["instruction"])) - - ordinary = RequirementsCommandHandler(repository, artifacts, default_registry()) - self.assertIsNone(ordinary.evaluation_review_context(task_id)) - - def test_author_and_reviewer_service_failures_record_usage_metadata(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - - class UnavailableAuthor: - async def call_tool(self, **_kwargs: object) -> dict[str, object]: - raise StructuredModelError("author unavailable") - - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=1, format_error_limit=1), repository, artifacts, runtime, - UnavailableAuthor(), None, RequirementsCommandHandler(repository, artifacts, default_registry()), - ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())), - ) - - async def run() -> list[tuple[str, dict[str, object]]]: - author = type("Model", (), {"provider_id": "test", "model_id": "test-model"})() - return [item async for item in workflow.run(task_id=task_id, author=author, reviewer=None)] - - asyncio.run(run()) - records = repository.usage_summary(task_id)["records"] - self.assertEqual(len(records), 1) - self.assertEqual(records[0]["retry_reason"], "provider_unavailable") - self.assertFalse(records[0]["usage_available"]) - self.assertFalse(records[0]["cache_hit"]) - self.assertGreater(records[0]["context_chars"], 0) - - def test_dynamic_requirement_claim_schema_rejects_unknown_expected_fields_before_write(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - batch = RequirementsDraftBatch.model_validate({"items": [{"source_ids": ["src_001"], "statement": "One solid", "assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1, "ignored": True}}]}]}) - result = handler.submit_draft(task_id, batch, invocation_id="draft_invalid") - self.assertEqual(result.error.code, ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertEqual(artifacts.read_requirements_draft(task_id)["items"], []) - self.assertEqual(repository.get_state(task_id).version, 0) - - def test_pattern_contract_uses_opaque_current_head_reference_enum(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("pattern_linear") - token = next(iter(runtime.reference_tokens({"features": [{"id": "feature_001"}]}))) - schema = fragment_schema(contract, selector_tokens=[], reference_tokens=[token]) - items = schema["properties"]["feature"]["properties"]["params"]["properties"]["source_feature_ids"]["items"] - self.assertEqual(items, {"enum": [token]}) - self.assertTrue(validate_fragment(contract, {"feature": {"atomic_id": "pattern_linear", "params": {"source_feature_ids": ["feature_001"], "direction_1": [1, 0, 0], "spacing_1_mm": 4, "pattern_count_1": 2}}}, selector_tokens=[], reference_tokens=[token])) - - def test_candidate_stage_recovery_advances_without_rebuild(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - stage = artifacts.start_candidate_stage(task_id, "candidate-key", {"idempotency_key": "candidate-key", "candidate_id": "candidate_123456789012", "action_id": action.action_id}) - artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", {"candidate_id": "candidate_123456789012", "stage_id": stage.stage_id, "action_id": action.action_id, "claim_results": [{"claim_id": "claim_001", "claim_kind": "solid_count_equals", "deterministic": True, "status": "pass"}]}) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id=stage.stage_id) - self.assertTrue(repository.compare_and_swap(building)) - result = actions.recover_candidate_build(task_id) - self.assertEqual(result.payload["status"], "awaiting_review") - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.CANDIDATE_REVIEW) - - def test_rejected_candidate_stage_is_retained_for_restart_diagnostics(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - stage = artifacts.start_candidate_stage(task_id, "rejected-stage-key", {"candidate_id": "candidate_123456789012"}) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id=stage.stage_id) - reviewing = transition(building, "candidate_built") - rejected = transition(reviewing, "candidate_rejected", repair_required=True, error=ErrorCode.CANDIDATE_REVIEW_REJECTED) - self.assertTrue(repository.compare_and_swap(building)) - self.assertTrue(repository.compare_and_swap(reviewing)) - self.assertTrue(repository.compare_and_swap(rejected, events=[{"event": "candidate_rejected", "candidate_id": "candidate_123456789012", "stage_id": stage.stage_id}])) - workflow = WorkflowCoordinator(WorkflowConfig(0, 1), repository, artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), actions) - - async def resume_once() -> list[tuple[str, dict[str, object]]]: - return [item async for item in workflow.run(task_id=task_id, author=None, reviewer=None)] - - asyncio.run(resume_once()) - self.assertTrue(Path(stage.output_dir).is_dir()) - - def test_published_candidate_review_recovery_commits_once(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - stage = artifacts.start_candidate_stage(task_id, "candidate-key", {"idempotency_key": "candidate-key", "candidate_id": "candidate_123456789012", "action_id": action.action_id}) - candidate = {"candidate_id": "candidate_123456789012", "stage_id": stage.stage_id, "action_id": action.action_id, "claim_results": [{"claim_id": "claim_001", "claim_kind": "solid_count_equals", "deterministic": True, "status": "pass"}]} - artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate) - review = CandidateReview.model_validate({"candidate_id": "candidate_123456789012", "working_head": action.working_head, "verdict": "accept", "claim_coverage": [{"claim_id": "claim_001", "status": "pass"}]}) - artifacts.write_stage_json(task_id, stage.stage_id, "candidate-review.json", review.model_dump(mode="json")) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id=stage.stage_id) - reviewing = transition(building, "candidate_built", candidate_id="candidate_123456789012", candidate_stage_id=stage.stage_id) - self.assertTrue(repository.compare_and_swap(building)) - self.assertTrue(repository.compare_and_swap(reviewing)) - artifacts.publish_candidate(task_id, stage.stage_id, "rev_001") - result = actions.recover_candidate_review(task_id) - self.assertEqual(result.payload["revision_id"], "rev_001") - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.AWAITING_ACTION) - self.assertEqual(len([item for item in repository.get_task_projection(task_id)["action_ledger_summary"] if item.get("event") == "accepted"]), 1) - - def test_candidate_publication_rebases_render_evidence_to_the_revision(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - artifacts = FileArtifactStore(root / "tasks") - task_id = "cad_123456abcdef" - artifacts.initialize_task(task_id, "Create a plate.") - stage = artifacts.start_candidate_stage(task_id, "publication-key", {"candidate_id": "candidate_123456789012"}) - stage_root = Path(stage.output_dir) - render = stage_root / "renders" / "contact-sheet.jpg" - render.parent.mkdir(parents=True) - render.write_bytes(b"jpeg") - artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", {"render_manifest": {"contact_sheet_path": str(render)}}) - artifacts.publish_candidate(task_id, stage.stage_id, "rev_001") - candidate = artifacts.read_json(task_id, "revisions/rev_001/candidate.json") - path = Path(str(candidate["render_manifest"]["contact_sheet_path"])) - self.assertEqual(path, artifacts.task_dir(task_id) / "revisions" / "rev_001" / "renders" / "contact-sheet.jpg") - self.assertTrue(path.is_file()) - - def test_complete_task_is_revision_scoped_idempotent(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - # A real candidate would have produced these immutable facts before - # action selection becomes available again. - artifacts.write_json_once(task_id, "revisions/rev_001/rebuild-report.json", {"health": {"solid_count": 1, "bbox_mm": {"dimensions": [10, 10, 1]}}}) - artifacts.write_json_once(task_id, "revisions/rev_001/model.topology.json", {"records": []}) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id="stage_1234567890abcdef12") - self.assertTrue(repository.compare_and_swap(building)) - awaiting = transition(building, "candidate_built") - self.assertTrue(repository.compare_and_swap(awaiting)) - awaiting = transition(awaiting, "candidate_accepted", active_revision="rev_001", repair_required=False) - self.assertTrue(repository.compare_and_swap(awaiting)) - first = actions.complete_task(task_id, invocation_id="complete_first") - second = actions.complete_task(task_id, invocation_id="complete_duplicate") - self.assertEqual(first.payload, second.payload) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FINAL_VALIDATION) - - def test_unexpected_final_review_failure_preserves_checkpoint_once(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - building = transition(state, "candidate_started", candidate_id="candidate_123456789012", candidate_stage_id="stage_1234567890abcdef12") - reviewing = transition(building, "candidate_built") - awaiting = transition(reviewing, "candidate_accepted", active_revision="rev_001") - final = transition(awaiting, "final_requested") - for value in (building, reviewing, awaiting, final): - self.assertTrue(repository.compare_and_swap(value)) - - class BrokenActions(ActionCommandHandler): - def _facts(self, task_id: str, revision_id: str) -> dict[str, object]: - raise NameError("global_violations") - - broken = BrokenActions(actions.repository, actions.artifacts, actions.runtime, actions.verifiers) - workflow = WorkflowCoordinator(WorkflowConfig(2, 1), repository, artifacts, actions.runtime, None, None, RequirementsCommandHandler(repository, artifacts, default_registry()), broken) - - async def run() -> list[tuple[str, dict[str, object]]]: - return [item async for item in workflow.run(task_id=task_id, author=None, reviewer=None)] - - events = asyncio.run(run()) - after = repository.get_state(task_id) - self.assertEqual(after.phase, TaskPhase.FAILED) - self.assertEqual(after.active_revision, "rev_001") - self.assertEqual(after.last_error, ErrorCode.FAILED_INTERNAL) - self.assertEqual(events[-1][1]["code"], ErrorCode.FAILED_INTERNAL.value) - - def test_final_review_requires_each_visual_claim_to_pass_independently(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - task_id = "cad_123456abcdef" - state = repository.create_task(task_id, "Create a visually inspectable single solid.") - artifacts.initialize_task(task_id, "Create a visually inspectable single solid.") - contract = { - "schema_version": "cad.requirements-contract.v1", - "task_id": task_id, - "contract_hash": "visual-contract", - "requirements": [{ - "requirement_id": "req_001", "draft_id": "draft_001", "source_ids": ["src_001"], - "statement": "One visible solid", "assumptions": [], - "acceptance_claims": [ - {"claim_id": "claim_001", "claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_id": "claim_002", "claim_kind": "visual", "expected": {"description": "The rendered part is visibly coherent."}}, - ], - }], - } - contract_path = artifacts.write_requirements_contract(task_id, contract, invocation_id="visual_contract") - for event in ("draft_updated", "requirements_finalized", "requirements_approved"): - state = transition(state, event, requirements_contract_path=contract_path if event == "requirements_approved" else None) - self.assertTrue(repository.compare_and_swap(state)) - state = transition(repository.get_state(task_id), "final_requested", active_revision="rev_001") - self.assertTrue(repository.compare_and_swap(state)) - artifacts.write_json_once(task_id, "revisions/rev_001/rebuild-report.json", {"health": {"solid_count": 1}}) - artifacts.write_json_once(task_id, "revisions/rev_001/model.topology.json", {"records": []}) - - result = actions.record_final_review( - task_id, - FinalReview.model_validate({ - "working_head": state.working_head, - "verdict": "pass", - "claim_coverage": [ - {"claim_id": "claim_001", "status": "pass"}, - {"claim_id": "claim_002", "status": "pending"}, - ], - }), - invocation_id="visual-review-pending", - ) - self.assertEqual(getattr(result, "payload", {}).get("status"), "repair") - after = repository.get_state(task_id) - self.assertEqual(after.phase, TaskPhase.AWAITING_ACTION) - self.assertTrue(after.repair_required) - event = repository.ledger_events(task_id)[-1] - self.assertEqual(event["event"], "final_review_repair") - self.assertEqual(event["visual_not_passed"], ["claim_002"]) - self.assertTrue(artifacts.read_json(task_id, "reviews/final/rev_001/final-review.json")) - self.assertIsNone(artifacts.read_json(task_id, "revisions/rev_001/final-review.json")) - - def test_action_rejections_do_not_create_stage_or_change_head(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - runtime = ProfileCadRuntime(settings(root)) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - registry = default_registry() - requirements = RequirementsCommandHandler(repository, artifacts, registry) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry)) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - batch = RequirementsDraftBatch.model_validate({"items": [{"source_ids": ["src_001"], "statement": "One plate", "assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 1}}]}]}) - requirements.submit_draft(task_id, batch, invocation_id="draft_1") - requirements.finalize_draft(task_id, invocation_id="finalize_1") - requirements.record_review(task_id, RequirementsReview.model_validate({"findings": []}), invocation_id="review_1") - state = repository.get_state(task_id) - self.assertIsNotNone(state) - proposal = NextAction.model_validate({"working_head": state.working_head, "intent": "Create base", "requirement_ids": ["req_001"], "atomic_id": "extrude_add_blind", "expected_change": "Add base"}) - self.assertTrue(hasattr(actions.propose_next_action(task_id, proposal, invocation_id="inv_1"), "payload")) - before = repository.get_state(task_id) - rejected = actions.submit_cdsl_fragment(task_id, {"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": -1}}}, invocation_id="inv_2") - after = repository.get_state(task_id) - self.assertEqual(rejected.error.code, ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertEqual(before.working_head, after.working_head) - self.assertEqual(list((artifacts.task_dir(task_id) / ".staging").iterdir()), []) - - def test_wrong_selector_kind_fails_the_handler_dynamic_schema_before_materialization(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository, artifacts, actions, task_id, _action = self._ready_action(Path(temporary)) - state = repository.get_state(task_id) - contract = actions.runtime.operation_contract("hole_blind") - pending = PendingAction("act_selector", state.working_head, "Cut bore", ("req_001",), "hole_blind", "Cut a bore", contract["contract_hash"], "selector-key") - selector_state = transition(state, "diagnosis_return_to_action_selection") - self.assertTrue(repository.compare_and_swap(selector_state)) - selector_state = transition(selector_state, "action_proposed", pending_action=pending) - self.assertTrue(repository.compare_and_swap(selector_state)) - artifacts.write_json_once(task_id, "revisions/rev_001/model.topology.json", { - "snapshot_id": "snapshot_001", - "records": [{ - "record_id": "edge_001", "kind": "edge", "executable": True, - "geometry": {"surface_type": "line"}, "owner_feature_ids": ["feature_001"], - }], - }) - # Point state at the topology-bearing checkpoint without changing - # the pending action head: this fixture only tests schema token - # narrowing, not a concurrent command transition. - current = repository.get_state(task_id) - repository.compare_and_swap(replace(current, version=current.version + 1, active_revision="rev_001")) - token = next(iter(actions.runtime.selector_tokens(artifacts.read_topology(task_id, "rev_001")))) - before = repository.get_state(task_id) - result = actions.submit_cdsl_fragment( - task_id, - {"feature": {"atomic_id": "hole_blind", "selector_tokens": [token], "params": {"diameter_mm": 2, "depth_mm": 4, "positions": [{"mm": [0, 0, 0]}]}}}, - invocation_id="wrong-selector-kind", - ) - after = repository.get_state(task_id) - self.assertEqual(result.error.code, ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertEqual(before.version, after.version) - self.assertEqual(list((artifacts.task_dir(task_id) / ".staging").iterdir()), []) - - def test_sqlite_cas_idempotency_and_outbox_are_atomic(self) -> None: + def test_sqlite_cas_and_outbox_are_atomic(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") - task_id = "cad_123456abcdef" - initial = repository.create_task(task_id, "x") - changed = transition(initial, "draft_updated") - self.assertTrue(repository.compare_and_swap(changed, events=[{"event": "draft"}])) + initial = repository.create_task("cad_123456abcdef", "Create a plate.") + changed = transition(initial, "image_observed") + self.assertTrue(repository.compare_and_swap(changed, events=[{"event": "image_observation_ready"}])) self.assertFalse(repository.compare_and_swap(changed, events=[{"event": "duplicate"}])) - invocation = repository.begin_invocation(task_id, "inv_a", "same-key") - repository.finish_invocation(invocation.invocation_id, {"ok": True}) - repeat = repository.begin_invocation(task_id, "inv_b", "same-key") - self.assertEqual(repeat.invocation_id, "inv_a") - self.assertEqual(repeat.result, {"ok": True}) - outbox = repository.pending_outbox() - self.assertEqual(outbox[0]["event"], "draft") - repository.mark_outbox_published(outbox[0]["event_id"]) - self.assertEqual(repository.pending_outbox(), []) - - def test_invocation_completion_rolls_back_with_a_failed_state_cas(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") - task_id = "cad_123456abcdef" - initial = repository.create_task(task_id, "x") - invocation = repository.begin_invocation(task_id, "inv_atomic", "atomic-key") - changed = transition(initial, "draft_updated") - - with self.assertRaisesRegex(ValueError, "Invocation is not an active"): - repository.compare_and_swap( - changed, - events=[{"event": "must_not_commit"}], - invocation_id="missing_invocation", - invocation_result={"status": "accepted"}, - ) - - self.assertEqual(repository.get_state(task_id).version, initial.version) - self.assertEqual(repository.ledger_events(task_id), []) - self.assertEqual(repository.get_invocation(task_id, invocation.invocation_id).status, "processing") - - self.assertTrue(repository.compare_and_swap( - changed, - events=[{"event": "committed"}], - invocation_id=invocation.invocation_id, - invocation_result={"status": "accepted"}, - )) - completed = repository.get_invocation(task_id, invocation.invocation_id) - self.assertEqual(completed.status, "finished") - self.assertEqual(completed.result, {"status": "accepted"}) - # A concurrent loser or legacy cleanup must never replace the - # result that was committed with the state transition. - repository.finish_invocation(invocation.invocation_id, {"status": "stale"}) - self.assertEqual(repository.get_invocation(task_id, invocation.invocation_id).result, {"status": "accepted"}) - - def test_outbox_retries_delivery_and_uses_a_stable_idempotency_key(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") - task_id = "cad_123456abcdef" - initial = repository.create_task(task_id, "x") - self.assertTrue(repository.compare_and_swap(transition(initial, "draft_updated"), events=[{"event": "draft"}])) - - class FlakyPublisher: - def __init__(self) -> None: - self.calls = 0 - self.events: list[dict[str, object]] = [] - - async def publish(self, event: dict[str, object]) -> None: - self.calls += 1 - if self.calls == 1: - raise RuntimeError("transport disconnected") - self.events.append(event) - - publisher = FlakyPublisher() - dispatcher = OutboxDispatcher(repository, publisher) - with self.assertRaisesRegex(RuntimeError, "transport disconnected"): - asyncio.run(dispatcher.dispatch_pending()) self.assertEqual(len(repository.pending_outbox()), 1) - delivered = asyncio.run(dispatcher.dispatch_pending()) + delivered = asyncio.run(OutboxDispatcher(repository, IdempotentInProcessPublisher()).dispatch_pending()) self.assertEqual(len(delivered), 1) - self.assertEqual(delivered[0]["event_id"], publisher.events[0]["event_id"]) self.assertEqual(repository.pending_outbox(), []) - idempotent = IdempotentInProcessPublisher() - asyncio.run(idempotent.publish(delivered[0])) - asyncio.run(idempotent.publish(delivered[0])) - self.assertEqual(len(idempotent.delivered), 1) - - def test_outbox_dispatch_scope_does_not_acknowledge_another_task(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") - first = repository.create_task("cad_123456abcdef", "x") - second = repository.create_task("cad_abcdef123456", "y") - self.assertTrue(repository.compare_and_swap(transition(first, "draft_updated"), events=[{"event": "first"}])) - self.assertTrue(repository.compare_and_swap(transition(second, "draft_updated"), events=[{"event": "second"}])) - publisher = IdempotentInProcessPublisher() - first_events = asyncio.run(OutboxDispatcher(repository, publisher).dispatch_pending(task_id=first.task_id)) - self.assertEqual([item["task_id"] for item in first_events], [first.task_id]) - self.assertEqual([item["task_id"] for item in repository.pending_outbox()], [second.task_id]) - - def test_action_ledger_jsonl_replays_committed_sqlite_events_idempotently(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - task_id = "cad_123456abcdef" - state = repository.create_task(task_id, "Create a plate.") - artifacts.initialize_task(task_id, "Create a plate.") - updated = transition(state, "draft_updated") - self.assertTrue(repository.compare_and_swap(updated, events=[{"event": "requirements_draft_updated", "draft_revision": 1}])) - - events = repository.ledger_events(task_id) - self.assertEqual(artifacts.sync_action_ledger(task_id, events), "actions/action-ledger.jsonl") - self.assertEqual(artifacts.sync_action_ledger(task_id, events), "actions/action-ledger.jsonl") - ledger = artifacts.task_dir(task_id) / "actions" / "action-ledger.jsonl" - lines = [json.loads(line) for line in ledger.read_text(encoding="utf-8").splitlines()] - self.assertEqual(len(lines), 1) - self.assertEqual(lines[0]["schema_version"], "cad.action-ledger.v1") - self.assertEqual(lines[0]["sequence"], events[0]["sequence"]) - self.assertEqual(lines[0]["event"], "requirements_draft_updated") - - def test_domain_and_application_do_not_import_delivery_or_adapter_implementations(self) -> None: - forbidden_domain = ("fastapi", "sqlite", "pathlib", "app.services", "app.settings", "langchain", "engine") - forbidden_application = ("app.cad_agent.adapters", "app.services", "app.settings") - for path in (ROOT / "backend" / "app" / "cad_agent" / "domain").glob("*.py"): - source = path.read_text(encoding="utf-8") - self.assertFalse(any(token in source for token in forbidden_domain), path.name) - for path in (ROOT / "backend" / "app" / "cad_agent" / "application").glob("*.py"): - source = path.read_text(encoding="utf-8") - self.assertFalse(any(token in source for token in forbidden_application), path.name) - if __name__ == "__main__": unittest.main() diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index b7745f16..1735a15b 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -18,8 +18,7 @@ export function CadProgressPart({ data }: { data: CadProgress }) { const statusLabel = isRunning ? "进行中" : isWaiting ? data.lifecycle === "waiting_retry" ? "等待重试" : "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status; const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError || isWaiting ? AlertTriangle : Check; const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []); - const documentTitle = data.step === "requirements_review" ? "需求合同复核" : ""; - const documentMarkdown = data.step === "requirements_document" ? withoutRequirementsFilename(data.markdown || "") : data.markdown || ""; + const documentMarkdown = data.markdown || ""; return (
@@ -32,8 +31,7 @@ export function CadProgressPart({ data }: { data: CadProgress }) { {data.questions?.length ?
    {data.questions.map((question, index) =>
  • {question}
  • )}
: null} {data.issues?.length ?
    {data.issues.map((issue, index) =>
  • {issue}
  • )}
: null} {data.verificationWarnings?.length ?
验证风险 ({data.verificationWarnings.length})
    {data.verificationWarnings.map((warning, index) =>
  • {warning}
  • )}
: null} - {data.appliedNormalizations?.length ?
自动归一化 ({data.appliedNormalizations.length})
: null} - {documentMarkdown ?
{documentTitle || "文档内容"}{documentMarkdown}
: null} + {documentMarkdown ?
文档内容{documentMarkdown}
: null} {data.arguments ?
调用参数
: null} {data.result !== undefined ?
执行结果
: null} {evidence.length ?
证据 ({evidence.length})
    {evidence.map((item, index) =>
  • {item}
  • )}
: null} @@ -41,13 +39,6 @@ export function CadProgressPart({ data }: { data: CadProgress }) { ); } -function withoutRequirementsFilename(markdown: string) { - return markdown - .replace(/^\s*#{1,6}\s*`?requirements\.md`?\s*\n+/i, "") - .replace(/^\s*`?requirements\.md`?\s*\n+/i, "") - .trimStart(); -} - export function CadResultPart({ data }: { data: CadResult }) { const downloads: Array<[string, string]> = data.checkpoint ? [] : [ ["STEP", data.stepPath], @@ -69,7 +60,6 @@ export function CadResultPart({ data }: { data: CadResult }) {
{data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} {data.verificationWarnings?.length ?
验证风险{data.verificationWarnings.join(";")}
: null} - {data.appliedNormalizations?.length ?
自动归一化 ({data.appliedNormalizations.length})
: null} {downloads.length ?
{downloads.map(([label, path]) => ( diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index 4c44b592..46f163a8 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -32,7 +32,6 @@ function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boo lifecycle: task.lifecycle || "completed", verificationStatus: task.verification_status, verificationWarnings: task.verification_warnings || [], - appliedNormalizations: task.applied_normalizations || [], }; } diff --git a/frontend/src/lib/cad-messages.ts b/frontend/src/lib/cad-messages.ts index b1628a6c..a19be29e 100644 --- a/frontend/src/lib/cad-messages.ts +++ b/frontend/src/lib/cad-messages.ts @@ -77,7 +77,6 @@ export function restoreTaskProjection(messages: CadUIMessage[], task: TaskRecord userActionRequired: Boolean(task.user_action_required), verificationStatus: task.verification_status, verificationWarnings: task.verification_warnings || [], - appliedNormalizations: task.applied_normalizations || [], }; return [...messages, { id: `projection_${task.task_id}_${task.state_version || 0}`, diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index 2affc453..cbdb435d 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -46,7 +46,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { event: "cad_error", data: { stage: "generation", - tool: "patch_requirements_draft", + tool: "submit_requirements_spec", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }, @@ -54,7 +54,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { assert.equal(chunk?.type, "data-cad-error"); assert.deepEqual("data" in chunk! ? chunk.data : null, { stage: "generation", - tool: "patch_requirements_draft", + tool: "submit_requirements_spec", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }); @@ -69,7 +69,7 @@ test("maps a waiting terminal into a visible user-decision state", () => { lifecycle: "waiting_for_user", message: "Requirements need a user decision.", questions: [question], - reviewPath: "documents/requirements-review.json", + clarificationPath: "documents/requirements-clarification.json", }, }, "text_1"); assert.equal(chunk?.type, "data-cad-progress"); @@ -81,7 +81,7 @@ test("maps a waiting terminal into a visible user-decision state", () => { taskId: "cad_abc", lifecycle: "waiting_for_user", questions: [question], - reviewPath: "documents/requirements-review.json", + clarificationPath: "documents/requirements-clarification.json", }); }); @@ -90,7 +90,7 @@ test("restores a complete waiting terminal from the task projection", () => { task_id: "cad_abc", current_revision: "", lifecycle: "waiting_for_user", phase: "WAITING_FOR_USER", state_version: 7, message: "A decision is required.", questions: ["Four holes or six?"], blocker_type: "requirements_ambiguity", user_action_required: true, - verification_status: "verified", verification_warnings: [], applied_normalizations: [], revisions: [], + verification_status: "pending", verification_warnings: [], revisions: [], }); const part = restored[0].parts[0]; assert.equal(part.type, "data-cad-progress"); @@ -101,14 +101,14 @@ test("restores a complete waiting terminal from the task projection", () => { } }); -test("keeps unmet review issues visible when requirements review cannot converge", () => { - const issue = "The counterbores need a feature-scoped front-side claim."; +test("keeps explicit runtime issues visible when generation stops", () => { + const issue = "The selected host face is unavailable."; const chunk = backendEventToUiChunk({ event: "task_terminal", data: { taskId: "cad_abc", lifecycle: "waiting_for_user", - message: "Requirements review did not converge.", + message: "Runtime validation stopped generation.", issues: [issue], }, }, "text_1"); @@ -117,7 +117,7 @@ test("keeps unmet review issues visible when requirements review cannot converge step: "task_terminal", label: "生成任务", status: "waiting", - message: "Requirements review did not converge.", + message: "Runtime validation stopped generation.", taskId: "cad_abc", lifecycle: "waiting_for_user", issues: [issue], diff --git a/frontend/src/lib/cad-stream.ts b/frontend/src/lib/cad-stream.ts index cd52af3c..e9bd54a0 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -20,7 +20,7 @@ export function backendEventToUiChunk( data: { ...item.data, sequence }, }; } - if (["requirements_review", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) { + if (["image_observation", "requirements_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) { const review = item.data.review && typeof item.data.review === "object" ? item.data.review as Record : null; @@ -44,7 +44,7 @@ export function backendEventToUiChunk( type: "data-cad-progress", id: `event_${eventId}`, data: { step: item.event, label: ({ - requirements_review: "需求合同独立复核", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务", + image_observation: "参考图片观察", requirements_ready: "需求规格已就绪", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务", } as Record)[item.event], status, ...metadata, message: String( item.data.message || item.data.reason || (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : "") @@ -56,13 +56,11 @@ export function backendEventToUiChunk( ...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}), ...(Array.isArray(item.data.questions) ? { questions: item.data.questions.map(String).filter(Boolean) } : {}), ...(Array.isArray(item.data.issues) ? { issues: item.data.issues.map(String).filter(Boolean) } : {}), - ...(Array.isArray(item.data.unresolved) ? { unresolved: item.data.unresolved } : {}), ...(item.data.blockerType ? { blockerType: String(item.data.blockerType) } : {}), ...(typeof item.data.userActionRequired === "boolean" ? { userActionRequired: item.data.userActionRequired } : {}), ...(item.data.verificationStatus ? { verificationStatus: String(item.data.verificationStatus) } : {}), ...(Array.isArray(item.data.verificationWarnings) ? { verificationWarnings: item.data.verificationWarnings.map(String).filter(Boolean) } : {}), - ...(Array.isArray(item.data.appliedNormalizations) ? { appliedNormalizations: item.data.appliedNormalizations } : {}), - ...(item.data.reviewPath ? { reviewPath: String(item.data.reviewPath) } : {}), + ...(item.data.clarificationPath ? { clarificationPath: String(item.data.clarificationPath) } : {}), ...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}), ...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}), ...(item.data.tool ? { tool: String(item.data.tool) } : {}), diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 4fe69649..ea648bf5 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -18,14 +18,12 @@ export type CadProgress = { evidence?: string[]; questions?: string[]; issues?: string[]; - unresolved?: Array<{ draftId?: string; reasonCode?: string; question?: string }>; blockerType?: string; userActionRequired?: boolean; verificationStatus?: "verified" | "completed_with_risks" | string; verificationWarnings?: string[]; - appliedNormalizations?: Array>; review?: Record; - reviewPath?: string; + clarificationPath?: string; lifecycle?: "running" | "completed" | "failed" | "waiting_retry" | "waiting_for_user" | string; attempt?: number; maxAttempts?: number; @@ -47,7 +45,6 @@ export type CadResult = { lifecycle?: "running" | "completed" | "failed" | string; verificationStatus?: "verified" | "completed_with_risks" | string; verificationWarnings?: string[]; - appliedNormalizations?: Array>; }; export type CadError = { @@ -118,14 +115,16 @@ export type TaskRecord = { phase?: string; state_version?: number; active_candidate_id?: string; - requirements_draft?: Record | null; - requirements_draft_path?: string; - requirements_review?: Record | null; - requirements_review_path?: string; + requirements_spec?: Record | null; + requirements_spec_path?: string; + clarification_path?: string; requirements_contract?: Record | null; requirements_contract_path?: string; requirements_markdown?: string | null; - completion_markdown?: string | null; + completion_target_markdown?: string | null; + completion_target_path?: string; + completion_result_markdown?: string | null; + completion_result_path?: string; claim_summary?: Array<{ requirement_id: string; claim_id: string; @@ -145,7 +144,6 @@ export type TaskRecord = { user_action_required?: boolean; verification_status?: "verified" | "completed_with_risks" | string; verification_warnings?: string[]; - applied_normalizations?: Array>; revisions: TaskRevision[]; };