优化流程

This commit is contained in:
2026-09-02 13:51:35 +08:00
parent 97a03c290b
commit f3eb5dff54
71 changed files with 5290 additions and 515 deletions
+68
View File
@@ -153,6 +153,7 @@ class ProfileCadRuntime:
fragment,
selector_tokens=allowed_selectors,
reference_tokens=list(reference_tokens),
root_xy_datum=not bool((base_cdsl or {}).get("features")),
)
if errors:
raise RuntimeAdapterError(
@@ -262,6 +263,73 @@ class ProfileCadRuntime:
except Exception as error:
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
def rebuild_best_effort(
self,
cdsl: dict[str, Any],
output_dir: str,
task_id: str,
revision_id: str,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Build independent feature prefixes without discarding prior geometry.
A full CDSL document can contain several features even though the
action protocol normally appends one at a time. Engine validation is
all-or-nothing, so replay features in source order and retain each
executable feature. A failed feature is reported as a diagnostic;
it does not erase already executable geometry or prevent later,
independent features from being tried.
"""
features = cdsl.get("features") if isinstance(cdsl.get("features"), list) else []
accepted: list[dict[str, Any]] = []
accepted_ids: set[str] = set()
failures: list[dict[str, Any]] = []
latest: dict[str, Any] | None = None
for index, feature in enumerate(features):
if not isinstance(feature, dict):
failures.append({"feature_index": index, "feature_id": "", "message": "Feature must be an object."})
continue
feature_id = str(feature.get("id") or f"feature_{index + 1:03d}")
dependencies = [str(value) for value in feature.get("depends_on") or () if isinstance(value, str)]
unavailable = [value for value in dependencies if value not in accepted_ids]
if unavailable:
failures.append({
"feature_index": index,
"feature_id": feature_id,
"message": "Feature was skipped because an earlier dependency did not execute.",
"dependencies": unavailable,
})
continue
candidate = self._feature_subset(cdsl, [*accepted, feature])
try:
latest = self.rebuild(candidate, output_dir, task_id, revision_id)
except Exception as error:
failures.append({"feature_index": index, "feature_id": feature_id, "message": str(error)[:1000]})
continue
accepted.append(deepcopy(feature))
accepted_ids.add(feature_id)
if not accepted:
if failures:
raise RuntimeAdapterError(str(failures[0].get("message") or "RUNTIME_EXECUTION_FAILURE: no feature could be rebuilt"))
raise RuntimeAdapterError("RUNTIME_EXECUTION_FAILURE: CDSL document has no executable features")
# A failed later attempt may have left partial files in the stage.
# Rebuild the retained feature set once so all published artifacts are
# guaranteed to describe the same successful checkpoint.
latest = self.rebuild(self._feature_subset(cdsl, accepted), output_dir, task_id, revision_id)
return {**latest, "executed_feature_ids": sorted(accepted_ids)}, failures
@staticmethod
def _feature_subset(cdsl: dict[str, Any], features: list[dict[str, Any]]) -> dict[str, Any]:
document = deepcopy(cdsl)
document["features"] = deepcopy(features)
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else []
sketch_ids = {str(feature.get("sketch_id") or "") for feature in features}
document["geometry"] = {
**geometry,
"sketches": [deepcopy(sketch) for sketch in sketches if isinstance(sketch, dict) and str(sketch.get("id") or "") in sketch_ids],
}
return document
def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> None:
if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract")
@@ -19,6 +19,7 @@ class SqliteTaskRepository:
self.database_path = database_path
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self.protocol_reset = False
self._initialize()
@contextmanager
@@ -34,11 +35,27 @@ class SqliteTaskRepository:
def _initialize(self) -> None:
with self._lock, self._connection() as connection:
# Protocol 3.1 intentionally has no migration path from the
# structured-only / review-loop task model. Deployment starts with
# an empty task database, as those tasks do not have immutable
# Markdown source artifacts to compile from.
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").fetchone()
if existing is not None and "'3.1'" not in str(existing[0] or ""):
self.protocol_reset = True
connection.executescript("""
DROP TABLE IF EXISTS outbox;
DROP TABLE IF EXISTS tool_audits;
DROP TABLE IF EXISTS usage_records;
DROP TABLE IF EXISTS invocations;
DROP TABLE IF EXISTS ledger;
DROP TABLE IF EXISTS model_capabilities;
DROP TABLE IF EXISTS tasks;
""")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.0'),
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.1'),
request TEXT NOT NULL,
phase TEXT NOT NULL,
state_version INTEGER NOT NULL,
@@ -50,6 +67,9 @@ class SqliteTaskRepository:
last_error TEXT,
retry_from_phase TEXT NOT NULL DEFAULT '',
requirements_spec_path TEXT NOT NULL DEFAULT '',
requirements_document_path TEXT NOT NULL DEFAULT '',
completion_target_path TEXT NOT NULL DEFAULT '',
modeling_plan_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,
@@ -104,8 +124,8 @@ class SqliteTaskRepository:
def create_task(self, task_id: str, request: str) -> TaskState:
with self._lock, self._connection() as connection:
connection.execute(
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.0', ?, ?, 0)",
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS.value),
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.1', ?, ?, 0)",
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
)
state = self.get_state(task_id)
if state is None:
@@ -130,7 +150,7 @@ class SqliteTaskRepository:
for item in events
if item.get("event") == "accepted" and isinstance(item.get("revision_id"), str)
]
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_contract_frozen"), {})
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
verification_warnings = [
str(item) for item in frozen.get("verification_warnings") or () if str(item)
] if isinstance(frozen, dict) else []
@@ -141,12 +161,13 @@ class SqliteTaskRepository:
"no_progress_limit",
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
"failed_author_format", "runtime_contract_invalid",
"completed_best_effort",
}
), {}) if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
), {}) if state.phase in {TaskPhase.COMPLETED, 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 []
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",
"schema_version": "3.1",
"task_id": state.task_id,
"phase": state.phase.value,
"lifecycle": self._lifecycle(state.phase),
@@ -160,10 +181,13 @@ class SqliteTaskRepository:
"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_spec_path": state.requirements_spec_path,
"requirements_document_path": state.requirements_document_path,
"completion_target_path": state.completion_target_path,
"modeling_plan_path": state.modeling_plan_path,
"clarification_path": state.clarification_path,
"requirements_contract_path": state.requirements_contract_path,
"verification_status": (
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and verification_warnings
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED)
else "verified" if state.phase == TaskPhase.COMPLETED
else "pending"
),
@@ -219,7 +243,7 @@ class SqliteTaskRepository:
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_spec_path = ?,
clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
WHERE task_id = ? AND state_version = ?""",
(
state.phase.value, state.version, state.active_revision,
@@ -227,6 +251,7 @@ class SqliteTaskRepository:
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_spec_path,
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
state.clarification_path, state.requirements_contract_path,
state.task_id, previous_version,
),
@@ -377,6 +402,9 @@ class SqliteTaskRepository:
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_spec_path=str(row["requirements_spec_path"] or ""),
requirements_document_path=str(row["requirements_document_path"] or ""),
completion_target_path=str(row["completion_target_path"] or ""),
modeling_plan_path=str(row["modeling_plan_path"] or ""),
clarification_path=str(row["clarification_path"] or ""),
requirements_contract_path=str(row["requirements_contract_path"] or ""),
)
@@ -109,8 +109,8 @@ class ActionCommandHandler:
for event in self.repository.ledger_events(task_id)[-16:]:
if event.get("event") in {
"candidate_rejected", "candidate_build_failed", "candidate_recovery_failed",
"candidate_recovered_rejected", "final_review_repair",
}:
"candidate_recovered_rejected", "candidate_operation_skipped", "final_review_repair",
} or (event.get("event") == "accepted" and event.get("repair_required")):
sequence = event.get("sequence")
if isinstance(sequence, int):
refs.append(f"evidence_ledger_{sequence}")
@@ -134,8 +134,10 @@ class ActionCommandHandler:
"candidate_build_failed",
"candidate_recovery_failed",
"candidate_recovered_rejected",
"candidate_operation_skipped",
"runtime_precondition_rejected",
"final_review_repair",
"accepted",
}
diagnostics: list[dict[str, Any]] = []
for event in reversed(self.repository.ledger_events(task_id)):
@@ -153,6 +155,20 @@ class ActionCommandHandler:
message = event.get("message")
if isinstance(message, str) and message:
item["message"] = message[:500]
for field in ("issues", "failed_checklist_items"):
values = event.get(field)
if isinstance(values, list):
item[field] = [str(value)[:500] for value in values[:8] if isinstance(value, str)]
failures = event.get("operation_failures")
if isinstance(failures, list):
item["operation_failures"] = [
{key: str(value)[:500] for key, value in failure.items() if key in {"feature_id", "message"}}
for failure in failures[:8]
if isinstance(failure, dict)
]
fragment_hash = event.get("fragment_hash")
if isinstance(fragment_hash, str) and fragment_hash:
item["fragment_hash"] = fragment_hash
stage_id = event.get("stage_id")
candidate = None
if isinstance(stage_id, str) and stage_id:
@@ -263,7 +279,11 @@ class ActionCommandHandler:
state = state or self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None:
return False
return any(
has_earlier_checkpoint = any(
revision != state.active_revision
for revision in self.checkpoint_tokens(task_id, state).values()
)
return has_earlier_checkpoint and any(
event.get("event") == "geometry_conclusion"
and event.get("decision") == "rollback"
and event.get("working_head") == state.working_head
@@ -390,6 +410,7 @@ class ActionCommandHandler:
fragment,
selector_tokens=allowed_selector_tokens,
reference_tokens=list(references),
root_xy_datum=not bool(state.active_revision),
)
if errors:
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "CDSL fragment violates the active operation schema.", field_errors=tuple(errors)))
@@ -452,8 +473,48 @@ class ActionCommandHandler:
if not self.repository.compare_and_swap(building, events=[{"event": "candidate_building", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "fragment_hash": audit["fragment_hash"]}]):
return Rejected(self._stale())
try:
rebuilt = self.runtime.rebuild(cdsl, stage.output_dir, task_id, candidate_id)
claim_results = self._evaluate_claims(task_id, rebuilt)
rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, stage.output_dir, task_id, candidate_id)
attempted_feature_ids = {str(value) for value in audit.get("assigned_feature_ids") or () if isinstance(value, str)}
executed_feature_ids = {str(value) for value in rebuilt.get("executed_feature_ids") or () if isinstance(value, str)}
if attempted_feature_ids and not attempted_feature_ids.intersection(executed_feature_ids):
next_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED)
result = {"candidate_id": candidate_id, "status": "skipped", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "operation_failures": operation_failures}
if not self._commit_invocation(next_state, [{
"event": "candidate_operation_skipped",
"candidate_id": candidate_id,
"stage_id": stage.stage_id,
"action_id": action.action_id,
"working_head": action.working_head,
"checkpoint_revision": state.active_revision,
"atomic_id": action.atomic_id,
"fragment_hash": fragment_hash,
"operation_failures": operation_failures,
"message": "The submitted feature did not execute; earlier executable features were retained.",
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(
ErrorCode.CANDIDATE_BUILD_FAILED,
"The submitted feature could not execute; the previous executable checkpoint was retained.",
details={"operation_failures": operation_failures},
))
try:
claim_results = self._evaluate_claims(task_id, rebuilt)
except Exception as error:
failed_state = transition(building, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID)
result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value}
if not self._commit_invocation(failed_state, [{
"event": "requirements_contract_execution_failed",
"candidate_id": candidate_id,
"stage_id": stage.stage_id,
"action_id": action.action_id,
"message": str(error)[:1000],
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(
ErrorCode.REQUIREMENTS_SPEC_INVALID,
"The frozen requirements contract could not be evaluated; no CAD repair was attempted.",
details={"diagnostic": str(error)[:1000]},
))
operation_results = self._operation_candidate_results(
action,
contract,
@@ -466,47 +527,11 @@ class ActionCommandHandler:
*self._candidate_blockers(task_id, action.requirement_ids, claim_results),
*[item for item in operation_results if item.get("status") != "pass"],
]
candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]}
candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "operation_failures": operation_failures, "executed_feature_ids": rebuilt.get("executed_feature_ids") or [], "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]}
self.artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate)
if blockers:
failure_class_fingerprint = self._failure_class_fingerprint(
state.active_revision, action.atomic_id, ErrorCode.CLAIM_VERIFICATION_FAILED,
)
prior_failures = self._failure_class_events(task_id, failure_class_fingerprint)
if len(prior_failures) >= 2:
failed_state = transition(building, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.NO_PROGRESS_LIMIT.value}
if not self._commit_invocation(failed_state, [{
"event": "no_progress_limit", "candidate_id": candidate_id, "stage_id": stage.stage_id,
"action_id": action.action_id, "checkpoint_revision": state.active_revision,
"atomic_id": action.atomic_id, "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value,
"failure_class_fingerprint": failure_class_fingerprint,
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(ErrorCode.NO_PROGRESS_LIMIT, "The same deterministic candidate failure made no progress after three attempts."))
failed_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED)
result = {"candidate_id": candidate_id, "status": "rejected", "code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, "claim_results": claim_results}
if not self._commit_invocation(failed_state, [{
"event": "candidate_rejected",
"candidate_id": candidate_id,
"stage_id": stage.stage_id,
"action_id": action.action_id,
"working_head": action.working_head,
"checkpoint_revision": state.active_revision,
"atomic_id": action.atomic_id,
"fragment_hash": fragment_hash,
"normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value,
"failure_exact_fingerprint": exact_fingerprint,
"failure_class_fingerprint": failure_class_fingerprint,
"attempt": len(prior_failures) + 1,
"reason": "deterministic_claim_failed",
"claim_results": claim_results,
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(ErrorCode.CLAIM_VERIFICATION_FAILED, "Candidate violates a deterministic claim required by this action or a global invariant.", details={"claim_results": blockers}))
review = transition(building, "candidate_built", candidate_id=candidate_id, candidate_stage_id=stage.stage_id)
result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results}
if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result):
result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results, "operation_failures": operation_failures}
if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results, "operation_failures": operation_failures}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
except OSError as error:
@@ -592,7 +617,9 @@ class ActionCommandHandler:
selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
references = self.runtime.reference_tokens(base)
cdsl, _audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=self._action_requires_through(task_id, action.requirement_ids))
rebuilt = self.runtime.rebuild(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id)
rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id)
if "operation_failures" not in locals():
operation_failures = []
claim_results = self._evaluate_claims(task_id, rebuilt)
audit = source.get("fragment_audit") if isinstance(source.get("fragment_audit"), dict) else {}
contract = self.runtime.operation_contract(action.atomic_id)
@@ -611,6 +638,7 @@ class ActionCommandHandler:
"fragment_hash": audit.get("fragment_hash") or canonical_hash(source.get("fragment") or {}),
"selector_snapshot_id": audit.get("selector_snapshot_id") or "", "claim_results": claim_results,
"operation_verifier_results": operation_results,
"operation_failures": operation_failures,
"blockers": [
*self._candidate_blockers(task_id, action.requirement_ids, claim_results),
*[item for item in operation_results if item.get("status") != "pass"],
@@ -699,51 +727,9 @@ class ActionCommandHandler:
event="candidate_review_storage_failure",
message=str(error),
)
if review.verdict != "accept" or deterministic_fail:
if published_revision:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "A published candidate cannot be rejected during recovery.", retryable=True))
failure_class_fingerprint = self._failure_class_fingerprint(
state.active_revision, action.atomic_id, ErrorCode.CANDIDATE_REVIEW_REJECTED,
)
reviewed_fragment_hash = str(candidate.get("fragment_hash") or "")
failure_exact_fingerprint = canonical_hash({
"active_revision": state.active_revision,
"atomic_id": action.atomic_id,
"fragment_hash": reviewed_fragment_hash,
}) if reviewed_fragment_hash else ""
prior_failures = self._failure_class_events(task_id, failure_class_fingerprint)
if len(prior_failures) >= 2:
next_state = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
result = {"candidate_id": state.candidate_id, "status": "failed", "code": ErrorCode.NO_PROGRESS_LIMIT.value}
if not self._commit_invocation(next_state, [{
"event": "no_progress_limit", "candidate_id": state.candidate_id,
"stage_id": state.candidate_stage_id, "action_id": action.action_id,
"checkpoint_revision": state.active_revision, "atomic_id": action.atomic_id,
"normalized_error_code": ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
"failure_class_fingerprint": failure_class_fingerprint,
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(ErrorCode.NO_PROGRESS_LIMIT, "The same candidate-review failure made no progress after three attempts."))
next_state = transition(state, "candidate_rejected", error=ErrorCode.CANDIDATE_REVIEW_REJECTED, repair_required=True)
result = {"candidate_id": state.candidate_id, "status": "rejected"}
if not self._commit_invocation(next_state, [{
"event": "candidate_rejected",
"candidate_id": state.candidate_id,
"stage_id": state.candidate_stage_id,
"action_id": action.action_id,
"working_head": action.working_head,
"review_path": "candidate-review.json",
"deterministic_fail": deterministic_fail,
"checkpoint_revision": state.active_revision,
"atomic_id": action.atomic_id,
"fragment_hash": reviewed_fragment_hash,
"failure_exact_fingerprint": failure_exact_fingerprint,
"normalized_error_code": ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
"failure_class_fingerprint": failure_class_fingerprint,
"attempt": len(prior_failures) + 1,
}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
operation_failures = candidate.get("operation_failures") if isinstance(candidate.get("operation_failures"), list) else []
candidate_blockers = candidate.get("blockers") if isinstance(candidate.get("blockers"), list) else []
needs_repair = review.verdict != "accept" or bool(deterministic_fail) or bool(operation_failures) or bool(candidate_blockers)
revision_id = self._next_revision(task_id)
if published_revision and published_revision != revision_id:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Published candidate revision does not match the current checkpoint lineage.", retryable=True))
@@ -755,9 +741,9 @@ class ActionCommandHandler:
event="candidate_publish_storage_failure",
message=str(error),
)
next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=False)
event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual}
result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted"}
next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=needs_repair, error=ErrorCode.CLAIM_VERIFICATION_FAILED if needs_repair else None)
event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual, "issues": list(review.issues), "operation_failures": operation_failures, "repair_required": needs_repair}
result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted_with_issues" if needs_repair else "accepted"}
if not self._commit_invocation(next_state, [event], invocation, result):
return Rejected(self._stale())
return Accepted(result)
@@ -833,21 +819,6 @@ class ActionCommandHandler:
invocation = self.repository.begin_invocation(task_id, f"{task_id}_recover_build_{secrets.token_hex(8)}", key) if key else None
if invocation is not None and invocation.status == "finished" and invocation.result is not None:
return Accepted(invocation.result)
if blockers:
failed = transition(state, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED)
result = {"candidate_id": state.candidate_id, "status": "rejected", "code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, "claim_results": claim_results}
event = {
"event": "candidate_recovered_rejected",
"candidate_id": state.candidate_id,
"stage_id": state.candidate_stage_id,
"action_id": action.action_id,
"working_head": action.working_head,
"claim_results": claim_results,
}
committed = self._commit_invocation(failed, [event], invocation, result) if invocation is not None else self.repository.compare_and_swap(failed, events=[event])
if not committed:
return Rejected(self._stale())
return Accepted(result)
review = transition(state, "candidate_built", candidate_id=state.candidate_id, candidate_stage_id=state.candidate_stage_id)
result = {"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "status": "awaiting_review", "claim_results": claim_results}
committed = self._commit_invocation(review, [{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result) if invocation is not None else self.repository.compare_and_swap(review, events=[{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}])
@@ -855,6 +826,43 @@ class ActionCommandHandler:
return Rejected(self._stale())
return Accepted(result)
def finalize_best_effort(self, task_id: str, *, reason: ErrorCode, invocation_id: str) -> Accepted | Rejected:
"""Publish the last executable checkpoint when further repair is bounded.
This is deliberately separate from strict final validation. It does
not claim that unmet acceptance targets passed; it makes the usable
model and its measured gaps durable instead of converting a planning
dead-end into a failed task with no deliverable.
"""
state = self.repository.get_state(task_id)
if state is None or not state.active_revision:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Best-effort completion requires an executable checkpoint."))
if state.phase == TaskPhase.COMPLETED:
return Accepted({"status": "completed", "revision_id": state.active_revision})
key = self._key(task_id, "best_effort_complete", state.active_revision, {"reason": reason.value})
invocation = self.repository.begin_invocation(task_id, invocation_id, key)
if invocation.status == "finished" and invocation.result is not None:
return Accepted(invocation.result)
claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision))
issues = [
f"{item.get('claim_kind')}: {item.get('status')}"
for item in claim_results
if item.get("status") != "pass"
]
next_state = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False)
result = {"status": "completed_with_warnings", "revision_id": state.active_revision, "claim_results": claim_results, "issues": issues}
if not self._commit_invocation(next_state, [{
"event": "completed_best_effort",
"revision_id": state.active_revision,
"termination_code": reason.value,
"message": "Further CAD repair was bounded; the last executable checkpoint was published.",
"issues": issues,
"claim_results": claim_results,
"completion_result_path": "completion-result.md",
}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
def record_final_review(self, task_id: str, review: FinalReview, *, invocation_id: str) -> Accepted | Rejected:
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.FINAL_VALIDATION:
@@ -901,6 +909,19 @@ class ActionCommandHandler:
message=str(error),
)
if review.verdict != "pass" or deterministic_fail or visual_not_passed:
accepted_fragment = next((
event.get("fragment_hash") for event in reversed(self.repository.ledger_events(task_id))
if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision
), "")
atomic_id = next((
str(event.get("actual_atomic_id") or "") for event in reversed(self.repository.ledger_events(task_id))
if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision
), "")
exact_fingerprint = canonical_hash({
"active_revision": state.active_revision,
"atomic_id": atomic_id,
"fragment_hash": accepted_fragment,
}) if accepted_fragment and atomic_id else ""
next_state = transition(state, "final_repair", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED)
result = {"status": "repair", "revision_id": state.active_revision}
if not self._commit_invocation(next_state, [{
@@ -909,6 +930,18 @@ class ActionCommandHandler:
"claim_results": claim_results,
"review_claim_coverage": [item.model_dump(mode="json") for item in review.claim_coverage],
"visual_not_passed": [str(item.get("claim_id") or "") for item in visual_not_passed],
"issues": list(review.issues),
"evidence": list(review.evidence),
"failed_checklist_items": [
str(requirement.get("statement") or "")
for requirement in (self._requirements_contract(task_id) or {}).get("requirements") or ()
if isinstance(requirement, dict)
and any(str(claim.get("claim_id") or "") in {str(item.get("claim_id") or "") for item in deterministic_fail + visual_not_passed} for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict))
],
"atomic_id": atomic_id,
"fragment_hash": accepted_fragment,
"failure_exact_fingerprint": exact_fingerprint,
"normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
@@ -9,12 +9,12 @@ from typing import Any, Literal
from app.cad_agent.application.llm_contracts import (
EmptyCommand,
ImageObservation,
RequirementsAuthorOutput,
MarkdownDocument,
StatelessCandidateReview,
StatelessGeometryConclusion,
StatelessRollbackCheckpoint,
StatelessTopologyRequest,
requirements_spec_schema,
compiled_requirements_schema,
stateless_final_review_schema,
stateless_next_action_schema,
)
@@ -37,7 +37,10 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
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("write_requirements_document", MarkdownDocument.model_json_schema()),
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
_tool("propose_next_action", stateless_next_action_schema(atomic_ids)),
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
@@ -54,7 +57,7 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
payload = {"protocol": "cad.v3.spec.v1", "role": role, "tools": tools}
payload = {"protocol": "cad.v3.1.markdown-first", "role": role, "tools": tools}
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -37,6 +37,28 @@ class SpecRequirementInput(StrictDto):
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
class MarkdownDocument(StrictDto):
"""A frozen human-readable design artifact, never an executable payload."""
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
class CompiledRequirementInput(StrictDto):
"""One verifier bundle for one server-parsed checklist item.
The checklist text, ordering, source bindings, and all identifiers are
intentionally absent: the service owns them after Markdown is frozen.
"""
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
class CompiledRequirementsSpec(StrictDto):
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
# Kept only so an interrupted process with an already imported old tool schema
# fails at the workflow boundary instead of failing module import. New v3.1
# tasks never expose or accept this aggregate specification.
class RequirementsSpec(StrictDto):
outcome: Literal["ready"]
summary: Annotated[str, Field(min_length=1, max_length=2000)]
@@ -61,7 +83,9 @@ class EmptyCommand(StrictDto):
class NextAction(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
intent: ShortText
requirement_ids: list[Identifier] = Field(min_length=1, max_length=5)
# The server binds this list from every frozen checklist target. It is not
# author input, so a five-item UI-era limit must not reject a valid task.
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
atomic_id: Identifier
expected_change: ShortText
@@ -194,6 +218,66 @@ def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
return schema
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
schema = CompiledRequirementsSpec.model_json_schema()
definitions = schema.get("$defs", {})
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
if isinstance(requirement, dict):
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
requirements = schema.get("properties", {}).get("requirements")
if isinstance(requirements, dict):
requirements["minItems"] = target_count
requirements["maxItems"] = target_count
return schema
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
"""Drop harmless compiler chatter before strict requirements validation.
``compile_requirements_spec`` is a compiler stage: the service only needs
the ordered verifier bundles for the frozen checklist items. Real models
sometimes add explanatory fields such as a top-level ``assumptions`` or
per-item ``statement`` even when the dynamic tool schema forbids them. Those
fields are not executable and are not part of the frozen contract, so they
should not abort a task before modeling starts.
The verifier ``expected`` payload is intentionally not sanitized here. It
remains governed by the registry's strict per-claim schema because those
values drive deterministic validation.
"""
value = canonical_json_object(raw_arguments_json)
if isinstance(value, WorkflowError):
return value
requirements = value.get("requirements")
sanitized: dict[str, Any] = {}
if isinstance(requirements, list):
sanitized_requirements: list[Any] = []
for requirement in requirements:
if not isinstance(requirement, dict):
sanitized_requirements.append(requirement)
continue
item: dict[str, Any] = {}
if "assumptions" in requirement:
item["assumptions"] = requirement["assumptions"]
if "acceptance_claims" in requirement:
claims = requirement["acceptance_claims"]
if isinstance(claims, list):
item["acceptance_claims"] = [
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
if isinstance(claim, dict) else claim
for claim in claims
]
else:
item["acceptance_claims"] = claims
sanitized_requirements.append(item)
sanitized["requirements"] = sanitized_requirements
else:
sanitized["requirements"] = requirements
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
schema = StatelessNextAction.model_json_schema()
properties = schema.get("properties", {})
@@ -228,18 +312,6 @@ def topology_request_schema(working_head: str) -> dict[str, Any]:
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()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
evidence = properties.get("evidence_refs")
if isinstance(evidence, dict):
evidence["items"] = {"enum": evidence_refs}
return schema
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
"""Bind a rollback request to immutable checkpoints in the active lineage."""
schema = RollbackCheckpoint.model_json_schema()
+210 -234
View File
@@ -1,26 +1,32 @@
"""One-pass requirements specification and server-owned contract artifacts."""
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any
import re
from typing import Any, Callable
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.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
from app.cad_agent.application.results import Accepted, Rejected
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
from app.cad_agent.domain.verifier_registry import VerifierRegistry
from app.cad_agent.ports import ArtifactStore, TaskRepository
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
class RequirementsCommandHandler:
"""Persist frozen documents and compile their checklist into a contract.
The model never names targets or internal objects during compilation. The
service derives those values strictly from the immutable checklist.
"""
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
self.repository = repository
self.artifacts = artifacts
@@ -28,283 +34,257 @@ class RequirementsCommandHandler:
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."""
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
self._evaluation_capability_gaps[task_id] = [
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
for item in validation_capability_gaps or ()
if isinstance(item, dict)
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 {
return None if claims is None else {
"evaluation_only": True,
"required_claims": deepcopy(claims),
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
}
def spec_schema(self) -> dict[str, Any]:
return requirements_spec_schema(self.registry.expected_one_of_schema())
@staticmethod
def document_schema() -> dict[str, Any]:
return MarkdownDocument.model_json_schema()
def submit_spec(self, task_id: str, output: RequirementsAuthorOutput, *, invocation_id: str) -> Accepted | Rejected | Waiting:
def compiler_schema(self, task_id: str) -> dict[str, Any]:
return compiled_requirements_schema(
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
len(self._checklist_items(task_id)),
)
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_MODELING_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.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 state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
targets = self._checklist_items(task_id)
if len(output.requirements) != len(targets):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
field_errors = self._claim_errors(normalized_output)
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")),
)
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
source_ids = list(self.artifacts.read_source_index(task_id))
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)]
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
warnings = [
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
*compiler_warnings,
]
requirements: list[dict[str, Any]] = []
claim_position = 1
for position, item in enumerate(value.requirements, 1):
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 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",
})
for claim in compiled.acceptance_claims:
definition = self.registry.definition(claim.claim_kind)
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
claim_position += 1
requirements.append({
"requirement_id": f"req_{position:03d}",
"source_ids": source_ids,
"statement": 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()
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
contract = {"schema_version": "cad.requirements-contract.v3.1", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
try:
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec_payload)
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(
state,
"requirements_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):
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
return Rejected(self._stale())
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:
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'}", ""]
rows = ["# Completion Result", "", "## Checklist", ""]
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
rows.append(f"## {requirement.get('statement')}")
statuses: list[str] = []
evidence: list[str] = []
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], ""])
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
statuses.append(str(result.get("status") or "unknown"))
value = result.get("evidence")
if value:
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
rows.extend(f" - Evidence: {value}" for value in evidence)
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
def _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")}
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != phase:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
errors = validator(document.markdown)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
path = self.artifacts.write_json_once(task_id, f"documents/requirements-clarification-{sha256(clarification.question.encode()).hexdigest()[:12]}.json", payload)
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
except OSError as error:
return self._park_for_storage_retry(state, str(error))
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):
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
next_state = transition(state, event, **kwargs)
result = Accepted({"phase": next_state.phase.value, "path": written})
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
return Rejected(self._stale())
return result
def _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 _checklist_items(self, task_id: str) -> list[str]:
path = self.artifacts.task_dir(task_id) / "completion-target.md"
text = path.read_text(encoding="utf-8") if path.is_file() else ""
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
for requirement_index, requirement in enumerate(output.requirements):
for claim_index, claim in enumerate(requirement.acceptance_claims):
try:
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
except ValueError:
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
return errors
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
normalized = output.model_copy(deep=True)
warnings: list[str] = []
for target, requirement in zip(targets, normalized.requirements, strict=True):
normalized_claims = []
for claim in requirement.acceptance_claims:
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
warnings.append(
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
warnings.append(
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
)
continue
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
normalized_claims.append(claim)
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
"claim_kind": "visual",
"expected": {"description": target[:360]},
})]
return normalized, list(dict.fromkeys(warnings))
@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 {}),
))
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
return False
if RequirementsCommandHandler._target_describes_finished_envelope(target):
return False
return any(
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
for item in claims
)
@staticmethod
def _target_describes_finished_envelope(target: str) -> bool:
lowered = target.lower()
return any(token in lowered for token in (
"overall",
"total",
"finished part",
"entire part",
"whole part",
"bounding box",
"envelope",
"总",
"整体",
"成品",
"全高",
"包围盒",
))
@staticmethod
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
value = bbox_claim.expected.get("value")
if not isinstance(value, (int, float)):
return
for item in claims:
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
continue
expected = getattr(item, "expected", None)
if not isinstance(expected, dict) or "axial_span_mm" in expected:
continue
expected["axial_span_mm"] = value
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
return
@staticmethod
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
# Markdown is a human-facing semantic artifact. Its content is frozen
# verbatim and is not executable, so headings are guidance for the
# author rather than a server-enforced protocol.
return []
@staticmethod
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
errors: list[dict[str, str]] = []
if not values:
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
if len(values) != len(set(values)):
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
return errors
@staticmethod
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
return []
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
invocation = self.repository.get_invocation(task_id, invocation_id)
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
@staticmethod
def _restore(payload: dict[str, Any]) -> Accepted:
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | 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)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
@staticmethod
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("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"
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
@staticmethod
def _stale() -> WorkflowError:
@@ -312,9 +292,5 @@ class RequirementsCommandHandler:
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],
}])
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))
+308 -45
View File
@@ -17,11 +17,12 @@ 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, ImageObservation, NextAction,
RequirementsAuthorOutput, RollbackCheckpoint, StatelessCandidateReview,
CandidateReview, CompiledRequirementsSpec, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, MarkdownDocument, NextAction,
RollbackCheckpoint, StatelessCandidateReview,
StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint,
StatelessTopologyRequest, TopologyRequest,
canonical_json_object, canonical_validate, canonical_validate_schema,
sanitize_compiled_requirements_arguments,
stateless_final_review_schema,
stateless_next_action_schema, stateless_rollback_checkpoint_schema,
raw_arguments_hash, validate_one_tool_call,
@@ -230,16 +231,6 @@ class WorkflowCoordinator:
if state is None:
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."}
return
if state.requirements_contract_path and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
try:
self.requirements.ensure_rendered_contract_views(task_id, state)
except Exception as error:
yield self._service_failure(
task_id,
state,
WorkflowError(ErrorCode.STORAGE_FAILURE, str(error)[:1000], retryable=True),
)
return
if state.phase == TaskPhase.COMPLETED:
yield "task_terminal", self._projected_terminal(task_id, state)
return
@@ -264,7 +255,7 @@ class WorkflowCoordinator:
yield self._service_failure(task_id, state, recovered.error)
return
continue
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
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")
@@ -299,8 +290,8 @@ class WorkflowCoordinator:
}])
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)]
document_schema = self.requirements.document_schema()
tools = [self._tool("write_requirements_document", document_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
@@ -321,8 +312,8 @@ class WorkflowCoordinator:
return
continue
name, raw, usage = result
validation = canonical_validate(raw, RequirementsAuthorOutput)
dynamic_error = canonical_validate_schema(raw, spec_schema) if not isinstance(validation, WorkflowError) else None
validation = canonical_validate(raw, MarkdownDocument)
dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
@@ -333,7 +324,7 @@ class WorkflowCoordinator:
return
continue
invocation_id = self._invocation_id(task_id)
command = self.requirements.submit_spec(task_id, validation, invocation_id=invocation_id)
command = self.requirements.submit_requirements_document(task_id, validation, invocation_id=invocation_id)
if isinstance(command, Rejected):
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)
@@ -341,7 +332,106 @@ class WorkflowCoordinator:
yield terminal
return
continue
yield "requirements_ready", self._event(task_id, name, self._result_payload(command), "waiting" if isinstance(command, Waiting) else "success", usage)
event_payload = self._event(task_id, name, self._result_payload(command), "success", usage)
event_payload["markdown"] = validation.markdown
yield "requirements_document_ready", event_payload
feedback = []
continue
if state.phase in {TaskPhase.DRAFTING_COMPLETION_TARGET, TaskPhase.DRAFTING_MODELING_PLAN}:
document_schema = self.requirements.document_schema()
tool_name = "write_completion_target" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "write_modeling_plan"
tools = [self._tool(tool_name, document_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
validation = canonical_validate(raw, MarkdownDocument)
dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool=tool_name)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.requirements.submit_completion_target(task_id, validation, invocation_id=self._invocation_id(task_id)) if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else self.requirements.submit_modeling_plan(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool=tool_name)
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
event_payload = self._event(task_id, name, self._result_payload(command), "success", usage)
event_payload["markdown"] = validation.markdown
yield ("completion_target_ready" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "modeling_plan_ready"), event_payload
feedback = []
continue
if state.phase == TaskPhase.COMPILING_REQUIREMENTS:
compiler_schema = self.requirements.compiler_schema(task_id)
tools = [self._tool("compile_requirements_spec", compiler_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
sanitized_raw = sanitize_compiled_requirements_arguments(raw)
if isinstance(sanitized_raw, WorkflowError):
validation: CompiledRequirementsSpec | WorkflowError = sanitized_raw
else:
validation = canonical_validate(sanitized_raw, CompiledRequirementsSpec)
dynamic_error = canonical_validate_schema(sanitized_raw, compiler_schema) if not isinstance(validation, WorkflowError) and isinstance(sanitized_raw, str) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool="compile_requirements_spec")
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.requirements.submit_compiled_spec(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="compile_requirements_spec")
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.AWAITING_ACTION:
@@ -472,11 +562,7 @@ class WorkflowCoordinator:
continue
name, raw, usage = result
if 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)
@@ -484,6 +570,13 @@ 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))
if isinstance(command, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
@@ -675,6 +768,11 @@ class WorkflowCoordinator:
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
state = self.repository.get_state(task_id)
terminal = self._best_effort_terminal(task_id, state, ErrorCode.NO_PROGRESS_LIMIT, "The workflow reached its bounded turn limit.") if state else None
if terminal:
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
yield terminal
return
if state:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}])
@@ -683,6 +781,11 @@ class WorkflowCoordinator:
yield self._storage_failure(task_id, str(error))
except Exception as error:
state = self.repository.get_state(task_id)
terminal = self._best_effort_terminal(task_id, state, ErrorCode.FAILED_INTERNAL, str(error)[:1000]) if state else None
if terminal:
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
yield terminal
return
if state and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": str(error)[:1000]}])
@@ -711,6 +814,15 @@ class WorkflowCoordinator:
return None
details = budget.payload()
details["next_actor"] = actor
terminal = self._best_effort_terminal(
task_id,
state,
ErrorCode.CALL_BUDGET_EXHAUSTED,
"Configured model-call budget is exhausted; publishing the last executable checkpoint.",
budget=details,
)
if terminal:
return terminal
failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED)
self.repository.compare_and_swap(failed, events=[{
"event": "call_budget_exhausted",
@@ -728,6 +840,49 @@ class WorkflowCoordinator:
"userActionRequired": False,
}
def _best_effort_terminal(
self,
task_id: str,
state: TaskState,
reason: ErrorCode,
message: str,
*,
budget: dict[str, Any] | None = None,
) -> tuple[str, dict[str, Any]] | None:
if not state.active_revision or state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
return None
claim_results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision))
visual_claims = [
{"status": "not_reviewed", "evidence": "Final review was not reached before bounded completion."}
for item in claim_results
if not item.get("deterministic")
]
try:
self.requirements.write_completion_result(
task_id,
state,
claim_results=claim_results,
review={"visual_claims": visual_claims},
)
except OSError:
return None
completed = self.actions.finalize_best_effort(task_id, reason=reason, invocation_id=self._invocation_id(task_id))
if isinstance(completed, Rejected):
return None
return "task_terminal", {
"taskId": task_id,
"lifecycle": "completed",
"revisionId": state.active_revision,
"code": ErrorCode.BEST_EFFORT_COMPLETED.value,
"message": message,
"issues": completed.payload.get("issues") or [],
"verificationStatus": "completed_with_risks",
"verificationWarnings": completed.payload.get("issues") or [],
"completionResultPath": "completion-result.md",
"budget": budget or {},
"userActionRequired": False,
}
def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]:
"""Park a nonterminal task when a durable artifact operation fails."""
state = self.repository.get_state(task_id)
@@ -1045,6 +1200,8 @@ class WorkflowCoordinator:
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 not state.active_revision and action.atomic_id in {"extrude_add_blind", "extrude_add_two_sided"}:
description += " Root extrusion uses the world XY datum: workplane.origin_mm must be [0, 0, Z], normal [0, 0, 1], and x_dir [1, 0, 0]. Profile coordinates are local to that plane."
if action.atomic_id.startswith("hole_"):
description += (
" Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. "
@@ -1057,7 +1214,7 @@ class WorkflowCoordinator:
"feature.selector_tokens is required author input: copy one of the opaque tokens from "
"inspect_topology; the server resolves it to the host after validation."
)
fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references))}}
fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}}
return [fragment]
def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
@@ -1076,18 +1233,41 @@ class WorkflowCoordinator:
state = self.repository.get_state(task_id)
if state is None:
return []
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
content = {
"protocol": "cad.v3.spec.v1",
"protocol": "cad.v3.1.markdown-first",
"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."
"Write the frozen engineering-expanded requirements Markdown using every required heading. Clearly separate explicit user facts from engineering defaults. "
"Conventional functional geometry is allowed for underspecified common parts, but never contradict explicit text or the image observation. Do not include runtime identifiers."
),
}
elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET:
content = {
"protocol": "cad.v3.1.markdown-first",
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"instruction": "Write # Completion Target with unique - [ ] checklist items. Each item must describe one independently observable final feature or condition. Do not add requirements not present in the frozen requirements document and do not include runtime identifiers.",
}
elif state.phase == TaskPhase.COMPILING_REQUIREMENTS:
content = {
"protocol": "cad.v3.1.markdown-first",
"source_requirements": self.artifacts.read_source_requirements(task_id),
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"verifier_registry": self.requirements.registry.expected_one_of_schema(),
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance.",
}
elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN:
content = {
"protocol": "cad.v3.1.markdown-first",
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"compiled_contract": self._requirements_contract(task_id, state),
"instruction": "Write # Modeling Plan with a short ordered list of feature-construction steps. It is a frozen execution guide only: do not add, remove, or reinterpret requirements and do not include runtime identifiers.",
}
else:
contract = self._requirements_contract(task_id, state) or {}
compact = [{
@@ -1117,7 +1297,7 @@ 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, "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}
content = {"protocol": "cad.v3.1", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), "modeling_plan_markdown": self._read_markdown(task_id, state.modeling_plan_path), "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
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:]]
@@ -1133,6 +1313,15 @@ class WorkflowCoordinator:
clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text})
return clarifications
def _read_markdown(self, task_id: str, relative_path: str) -> str:
if not relative_path:
return ""
try:
path = self.artifacts.artifact_path(task_id, relative_path)
return path.read_text(encoding="utf-8") if path.is_file() else ""
except (OSError, ValueError):
return ""
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
"""Expose the persisted requirement question when a task is parked.
@@ -1232,6 +1421,18 @@ class WorkflowCoordinator:
action = state.pending_action
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
@staticmethod
def _coordinate_protocol(state: TaskState) -> dict[str, str]:
protocol = {
"system": "world_mm_right_handed",
"sketch_mapping": "workplane.origin_mm is the world position of sketch local (0,0); profile points such as circle.center are sketch-local.",
"vectors": "normal is positive extrusion direction; x_dir is sketch local +X expressed in world coordinates.",
"hosted_features": "For existing solids, use the selected max_z/min_z face and its supplied normal; do not infer or hand-copy a world-space offset.",
}
if not state.active_revision:
protocol["root_extrusion"] = "Root extrude_add_blind and extrude_add_two_sided are bound to world XY: origin=[0,0,Z], normal=[0,0,1], x_dir=[1,0,0]. Requirements decide Z only; never place a Z offset in Y."
return protocol
@staticmethod
def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(payload, dict):
@@ -1265,7 +1466,7 @@ class WorkflowCoordinator:
topology = self.artifacts.read_topology(task_id, state.active_revision)
tokens = self.runtime.selector_tokens(topology)
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references))}
return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}
def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
constraints: list[dict[str, Any]] = []
@@ -1334,10 +1535,25 @@ class WorkflowCoordinator:
def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None:
"""Bound no-side-effect model rejections; park real service failures."""
if error.code == ErrorCode.NO_PROGRESS_LIMIT:
current = self.repository.get_state(task_id) or state
terminal = self._best_effort_terminal(
task_id,
current,
ErrorCode.NO_PROGRESS_LIMIT,
"Further attempts repeated an already failed CAD path; publishing the last executable checkpoint.",
)
if terminal:
return terminal
feedback[:] = [self._feedback(error)]
return None
if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID:
# Registry integrity is a deployment defect. Retrying a model with
# the same broken contract cannot repair it and must never consume
# the author-format budget.
terminal = self._best_effort_terminal(task_id, state, error.code, error.message)
if terminal:
return terminal
failed = transition(state, "failed", error=error.code)
self.repository.compare_and_swap(failed, events=[{
"event": "runtime_contract_invalid",
@@ -1350,6 +1566,27 @@ class WorkflowCoordinator:
"code": error.code.value,
"message": error.message,
}
if error.code == ErrorCode.REQUIREMENTS_SPEC_INVALID:
# A frozen verifier contract is service-owned input at this stage.
# CAD retries cannot repair it, so preserve the terminal diagnosis
# instead of parking the task or blaming the author fragment.
current = self.repository.get_state(task_id)
if current is not None and current.phase != TaskPhase.FAILED:
failed = transition(current, "failed", error=error.code)
self.repository.compare_and_swap(failed, events=[{
"event": "requirements_contract_execution_failed",
"tool": name,
"message": error.message,
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": error.code.value,
"message": error.message,
"blockerType": "requirements_contract_invalid",
"userActionRequired": False,
"issues": [str(error.details.get("diagnostic") or error.message)],
}
if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED:
# A schema-valid fragment can still be impossible on the current
# geometry. This is not an author-format failure: preserve the
@@ -1368,8 +1605,8 @@ class WorkflowCoordinator:
if event.get("failure_class_fingerprint") == failure_class_fingerprint
]
if len(prior) >= 2:
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(failed, events=[{
next_state = transition(state, "runtime_precondition_rejected", pending_action=None, error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(next_state, events=[{
"event": "no_progress_limit",
"tool": name,
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
@@ -1379,15 +1616,16 @@ class WorkflowCoordinator:
"normalized_error_code": normalized_code,
"failure_class_fingerprint": failure_class_fingerprint,
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The same operation failure class made no progress after three attempts; the last checkpoint was preserved.",
"blockerType": "no_progress_limit",
"userActionRequired": False,
"revisionId": state.active_revision,
}
terminal = self._best_effort_terminal(
task_id,
next_state,
ErrorCode.NO_PROGRESS_LIMIT,
"The same operation failure made no progress; publishing the last executable checkpoint.",
)
if terminal:
return terminal
feedback[:] = [self._feedback(error)]
return None
next_state = transition(
state,
"runtime_precondition_rejected",
@@ -1419,6 +1657,9 @@ class WorkflowCoordinator:
return None
# A precondition result outside fragment submission is an invalid
# workflow implementation state, not a provider/service outage.
terminal = self._best_effort_terminal(task_id, state, error.code, error.message)
if terminal:
return terminal
failed = transition(state, "failed", error=error.code)
self.repository.compare_and_swap(failed, events=[{
"event": "runtime_precondition_rejected",
@@ -1432,6 +1673,16 @@ class WorkflowCoordinator:
"code": error.code.value,
"message": error.message,
}
if error.code in {
ErrorCode.CANDIDATE_BUILD_FAILED,
ErrorCode.CLAIM_VERIFICATION_FAILED,
ErrorCode.CANDIDATE_REVIEW_REJECTED,
}:
# The handler already preserved the last checkpoint and recorded
# the field/runtime diagnostic. Continue with that evidence; a
# planning miss is not an infrastructure terminal condition.
feedback[:] = [self._feedback(error)]
return None
model_rejection_codes = {
ErrorCode.AUTHOR_FORMAT_INVALID,
ErrorCode.AUTHOR_DECISION_REJECTED,
@@ -1446,6 +1697,14 @@ class WorkflowCoordinator:
feedback[:] = [self._feedback(error)]
if counters[name] < self.config.format_error_limit:
return None
terminal = self._best_effort_terminal(
task_id,
state,
ErrorCode.FAILED_AUTHOR_FORMAT,
f"{actor.capitalize()} repeatedly failed the canonical schema; publishing the last executable checkpoint.",
)
if terminal:
return terminal
failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT)
self.repository.compare_and_swap(failed, events=[{"event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors)}])
return "task_terminal", {
@@ -1464,6 +1723,8 @@ class WorkflowCoordinator:
error: WorkflowError,
counters: dict[str, int],
feedback: list[dict[str, Any]],
*,
tool: str = "compile_requirements_spec",
) -> tuple[str, dict[str, Any]] | None:
key = "requirements_spec"
counters[key] = counters.get(key, 0) + 1
@@ -1482,7 +1743,7 @@ class WorkflowCoordinator:
"lifecycle": "failed",
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
"message": "Requirements specification remained unreadable after one field-level correction.",
"tool": "submit_requirements_spec",
"tool": tool,
"field_errors": list(error.field_errors),
"userActionRequired": False,
}
@@ -1494,10 +1755,12 @@ class WorkflowCoordinator:
error: WorkflowError,
counters: dict[str, int],
feedback: list[dict[str, Any]],
*,
tool: str = "compile_requirements_spec",
) -> 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)
return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool)
@staticmethod
def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]:
+5
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
import shutil
from app.cad_agent.adapters.artifact_store import FileArtifactStore
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
@@ -30,6 +31,10 @@ class V3Services:
def compose_v3(settings: Settings) -> V3Services:
repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3")
if repository.protocol_reset and settings.task_root.exists():
# Protocol 3.1 has no valid interpretation for structured-only task
# artifacts, so clear that task root together with its old database.
shutil.rmtree(settings.task_root)
artifacts = FileArtifactStore(settings.task_root)
runtime = ProfileCadRuntime(settings)
registry = default_registry()
+1
View File
@@ -29,6 +29,7 @@ class ErrorCode(StrEnum):
REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID"
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED"
FAILED_INTERNAL = "FAILED_INTERNAL"
WAITING_FOR_USER = "WAITING_FOR_USER"
@@ -131,7 +131,7 @@ def validate_operation_contract(contract: dict[str, Any]) -> None:
raise OperationContractError("Operation candidate verifiers are duplicated")
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> dict[str, Any]:
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> dict[str, Any]:
"""Build the one-operation schema exposed for one pending action."""
validate_operation_contract(contract)
shape = contract["fragment_shape"]
@@ -174,15 +174,15 @@ def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], ref
properties: dict[str, Any] = {"feature": feature}
required = ["feature"]
if shape["sketch"] == "required":
properties["sketch"] = _sketch_schema()
properties["sketch"] = _sketch_schema(root_xy_datum=root_xy_datum and contract["atomic_id"] in {"extrude_add_blind", "extrude_add_two_sided"})
required.insert(0, "sketch")
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False}
Draft202012Validator.check_schema(schema)
return schema
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> list[dict[str, str]]:
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens)
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> list[dict[str, str]]:
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens, root_xy_datum=root_xy_datum)
return [
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message))
@@ -193,10 +193,23 @@ def _point(size: int) -> dict[str, Any]:
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
def _sketch_schema() -> dict[str, Any]:
def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]:
point2 = _point(2)
point3 = _point(3)
workplane = {"type": "object", "properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)}, "required": ["origin_mm", "x_dir", "normal"], "additionalProperties": False}
workplane = {
"type": "object",
"description": "origin_mm is the world position of sketch local (0,0); profile coordinates are local to this plane. normal is positive extrusion direction and x_dir is local +X in world coordinates.",
"properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)},
"required": ["origin_mm", "x_dir", "normal"],
"additionalProperties": False,
}
if root_xy_datum:
workplane["description"] += " Root extrusion uses fixed world XY datum: origin X/Y are 0, normal is +Z, x_dir is +X. Only origin Z is task-defined."
workplane["properties"] = {
"origin_mm": {"type": "array", "prefixItems": [{"const": 0}, {"const": 0}, {"type": "number"}], "items": False, "minItems": 3, "maxItems": 3},
"x_dir": {"const": [1, 0, 0]},
"normal": {"const": [0, 0, 1]},
}
profile = {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False},
+27 -7
View File
@@ -9,7 +9,10 @@ from .errors import ErrorCode, WorkflowError
class TaskPhase(StrEnum):
DRAFTING_REQUIREMENTS = "DRAFTING_REQUIREMENTS"
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
AWAITING_ACTION = "AWAITING_ACTION"
ACTION_PENDING = "ACTION_PENDING"
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
@@ -47,6 +50,9 @@ class TaskState:
last_error: ErrorCode | None = None
retry_from_phase: TaskPhase | None = None
requirements_spec_path: str = ""
requirements_document_path: str = ""
completion_target_path: str = ""
modeling_plan_path: str = ""
clarification_path: str = ""
requirements_contract_path: str = ""
@@ -58,13 +64,16 @@ 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, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS,
(TaskPhase.DRAFTING_REQUIREMENTS, "requirements_approved"): TaskPhase.AWAITING_ACTION,
(TaskPhase.DRAFTING_REQUIREMENTS, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET,
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS,
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.DRAFTING_MODELING_PLAN,
(TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "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.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
@@ -83,6 +92,11 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
}
_TRANSITIONS.update({
(phase, "best_effort_completed"): TaskPhase.COMPLETED
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_TRANSITIONS.update({
(phase, "failed"): TaskPhase.FAILED
for phase in TaskPhase
@@ -94,7 +108,10 @@ _TRANSITIONS.update({
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_RETRY_RESUMABLE_PHASES = frozenset({
TaskPhase.DRAFTING_REQUIREMENTS,
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
TaskPhase.DRAFTING_COMPLETION_TARGET,
TaskPhase.COMPILING_REQUIREMENTS,
TaskPhase.DRAFTING_MODELING_PLAN,
TaskPhase.AWAITING_ACTION,
TaskPhase.ACTION_PENDING,
TaskPhase.CANDIDATE_BUILDING,
@@ -122,7 +139,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_spec_path: str | None = None, clarification_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, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_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:
@@ -144,6 +161,9 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
last_error=error,
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path,
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path,
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path,
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_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,
)
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from copy import deepcopy
from math import atan2, isclose, pi, sqrt
from typing import Any, Callable
@@ -12,6 +13,7 @@ from jsonschema.exceptions import SchemaError
ClaimResult = dict[str, Any]
ClaimEvaluator = Callable[[dict[str, Any], dict[str, Any]], ClaimResult]
_DEFAULT_TOLERANCE_MM = 0.1
def _closed_object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
@@ -49,7 +51,13 @@ def _cylinder_axis_span(record: dict[str, Any]) -> float | None:
def _cylinder_axis_interval(record: dict[str, Any]) -> tuple[float, float] | None:
"""Return the inclusive axial interval of a cylindrical face's bounds."""
"""Return the inclusive axial interval of a cylindrical face's bounds.
B-rep face orientation is not a physical property of a cylindrical shell.
In particular, a two-sided extrusion can return its two half-walls with
opposite axis directions. Use one canonical direction for an undirected
cylinder axis so those halves share the same coordinate interval.
"""
geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
bbox = geometry.get("bbox_mm")
direction = _cylinder_axis_direction(record)
@@ -60,6 +68,9 @@ def _cylinder_axis_interval(record: dict[str, Any]) -> tuple[float, float] | Non
maximum = tuple(float(value) for value in bbox[3:])
except (TypeError, ValueError):
return None
dominant_index = max(range(3), key=lambda index: abs(direction[index]))
if direction[dominant_index] < 0:
direction = tuple(-component for component in direction)
projections = [
sum(direction[index] * point[index] for index in range(3))
for point in (
@@ -418,7 +429,7 @@ def _outer_cylindrical_surface(expected: dict[str, Any], facts: dict[str, Any])
max(interval[1] for interval in shell_intervals if interval is not None)
- min(interval[0] for interval in shell_intervals if interval is not None)
)
tolerance = float(expected["tolerance_mm"])
tolerance = float(expected.get("tolerance_mm", _DEFAULT_TOLERANCE_MM))
evidence.update({"axial_spans_mm": actual_spans, "tolerance_mm": tolerance})
return _pass(evidence) if all(abs(span - float(axial_span)) <= tolerance for span in actual_spans) else _fail({"diameter_mm": expected["diameter_mm"], "expected_axial_span_mm": axial_span, "actual_axial_spans_mm": actual_spans, "tolerance_mm": tolerance})
@@ -790,11 +801,13 @@ class VerifierRegistry:
except KeyError as error:
raise ValueError(f"VERIFIER_UNAVAILABLE: {claim_kind}") from error
def expected_one_of_schema(self) -> dict[str, Any]:
def expected_one_of_schema(self, *, exclude_claim_kinds: set[str] | frozenset[str] | tuple[str, ...] = ()) -> dict[str, Any]:
excluded = set(exclude_claim_kinds)
return {
"oneOf": [
_closed_object({"claim_kind": {"const": item.claim_kind}, "expected": item.expected_schema}, ["claim_kind", "expected"])
for item in self._definitions.values()
if item.claim_kind not in excluded
]
}
@@ -806,6 +819,19 @@ class VerifierRegistry:
for error in sorted(validator.iter_errors(expected), key=lambda item: (list(item.absolute_path), item.message))
]
def normalize_expected(self, claim_kind: str, expected: dict[str, Any]) -> dict[str, Any]:
"""Apply protocol defaults before a compiled contract is frozen.
These defaults describe verifier mechanics, never user geometry. The
outer-cylinder verifier can match a diameter without a tolerance, but
measuring its optional axial span needs one. Persist the default so
the resulting contract is complete and independently reproducible.
"""
normalized = deepcopy(expected)
if claim_kind == "outer_cylindrical_surface" and "axial_span_mm" in normalized:
normalized.setdefault("tolerance_mm", _DEFAULT_TOLERANCE_MM)
return normalized
def evaluate(self, claim_kind: str, expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
errors = self.validate_expected(claim_kind, expected)
if errors:
+164
View File
@@ -0,0 +1,164 @@
"""Run real-provider CAD tasks with a usable-model success criterion.
This evaluator is intentionally narrower than the release gate in ``live.py``.
It answers one operational question: can the current workflow reliably finish
ordinary prompts with a downloadable/previewable checkpoint, even if some
acceptance claims remain best-effort warnings.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
from datetime import datetime, timezone
import json
from pathlib import Path
import secrets
import sys
from typing import Any
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.composition import compose_v3
from app.settings import BACKEND_ROOT, get_settings
DEFAULT_PROMPTS = (
"生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。",
"生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。",
"生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。",
)
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.")
parser.add_argument("--author-provider")
parser.add_argument("--author-model")
parser.add_argument("--review-provider")
parser.add_argument("--review-model")
parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.")
parser.add_argument("--max-wall-seconds", type=int, default=1200)
parser.add_argument("--max-model-calls", type=int, default=60)
return parser.parse_args()
def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str:
terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {})
for source in (terminal, projection):
code = source.get("code") or source.get("last_error") if isinstance(source, dict) else ""
if isinstance(code, str) and code:
return code
return ""
def _artifact_ok(artifact_root: Path, revision_id: str) -> bool:
if not revision_id:
return False
revision_root = artifact_root / "revisions" / revision_id
return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json"))
def _classify_failure(code: str, projection: dict[str, Any]) -> str:
if str(projection.get("lifecycle") or "") == "waiting_retry":
return "configuration_or_service"
if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}:
return "code_or_flow"
if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}:
return "configuration_or_service"
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}:
return "model_output_or_best_effort"
return "unknown"
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
settings = get_settings()
try:
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
if arguments.review_provider or arguments.review_model:
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
else:
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
return {"status": "blocked", "error": str(error), "results": []}
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
services = compose_v3(isolated)
services.workflow.config = replace(
services.workflow.config,
max_turns=max(8, arguments.max_model_calls * 3),
max_model_calls=arguments.max_model_calls,
)
prompts = tuple(arguments.prompt or DEFAULT_PROMPTS)
results: list[dict[str, Any]] = []
for index, prompt in enumerate(prompts, start=1):
task_id = f"cad_{secrets.token_hex(6)}"
services.workflow.create_task(task_id, prompt)
events: list[dict[str, Any]] = []
started = datetime.now(timezone.utc)
try:
async with asyncio.timeout(arguments.max_wall_seconds):
async for name, payload in services.workflow.run(
task_id=task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
):
events.append({"name": name, "payload": payload})
except TimeoutError:
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}})
projection = services.repository.get_task_projection(task_id) or {}
artifact_root = (isolated.task_root / task_id).resolve()
revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "")
code = _result_code(events, projection)
usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id)
results.append({
"index": index,
"task_id": task_id,
"prompt": prompt,
"success": usable,
"failure_layer": "" if usable else _classify_failure(code, projection),
"code": code,
"phase": projection.get("phase"),
"lifecycle": projection.get("lifecycle"),
"active_revision": revision_id,
"verification_status": projection.get("verification_status"),
"artifact_root": str(artifact_root),
"duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000),
"usage": services.repository.usage_summary(task_id),
"event_audit": [
{
"name": item.get("name"),
"status": (item.get("payload") or {}).get("status"),
"lifecycle": (item.get("payload") or {}).get("lifecycle"),
"code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"),
"tool": (item.get("payload") or {}).get("tool"),
}
for item in events
],
})
return {
"status": "passed" if results and all(item["success"] for item in results) else "failed",
"schema_version": "cad.usable-smoke.v1",
"author": {"provider": author_provider.id, "model": author_model.id},
"reviewer": {"provider": review_provider.id, "model": review_model.id},
"results": results,
}
def main() -> int:
arguments = _arguments()
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
report_root.mkdir(parents=True, exist_ok=True)
try:
result = asyncio.run(_run(arguments, report_root))
except KeyboardInterrupt:
raise
except BaseException as error:
result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []}
result.update({"report_root": str(report_root.resolve())})
report_path = report_root / "usable-smoke-report.json"
report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False))
return 0 if result["status"] == "passed" else 2
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -88,6 +88,7 @@ class CadRuntime(Protocol):
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ...
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]: ...
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
def rebuild_best_effort(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: ...
class ModelGateway(Protocol):
+25 -4
View File
@@ -154,11 +154,16 @@ async def read_task(task_id: str) -> JSONResponse:
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
target_path = agent.v3.artifacts.task_dir(safe_id) / "completion-target.md"
task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"])
requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None
task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None
target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.modeling_plan_path) if state is not None and state.modeling_plan_path else None
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_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None
task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else ""
task["modeling_plan_markdown"] = plan_path.read_text(encoding="utf-8") if plan_path and plan_path.is_file() else None
task["modeling_plan_path"] = state.modeling_plan_path if plan_path and plan_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)
@@ -203,6 +208,22 @@ def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[st
return result
def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Expose server-evaluated checklist progress without parsing Markdown."""
by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)}
progress: list[dict[str, Any]] = []
for requirement in (contract or {}).get("requirements") or ():
if not isinstance(requirement, dict):
continue
statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
progress.append({
"requirement_id": str(requirement.get("requirement_id") or ""),
"statement": str(requirement.get("statement") or ""),
"status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending",
})
return progress
@app.delete("/v1/tasks/{task_id}")
async def cancel_task(task_id: str) -> JSONResponse:
try:
+4 -1
View File
@@ -33,7 +33,10 @@ def _response_language(text: str) -> str:
_EVENT_LABELS = {
"image_observation": "参考图片观察",
"requirements_ready": "需求规格已就绪",
"requirements_document_ready": "需求文档已冻结",
"completion_target_ready": "完成目标已冻结",
"requirements_compiled": "需求合同已编译",
"modeling_plan_ready": "建模计划已冻结",
"completion_result_ready": "完成结果已就绪",
"model_protocol_check": "模型协议检查",
"action_selection": "动作选择",
+18 -1
View File
@@ -8,7 +8,24 @@ from __future__ import annotations
from .llm_compiler import compile_cdsl
from .llm_engine import run_engine_plan
from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild
try:
from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild
except ModuleNotFoundError as exc:
# The legacy rebuild façade optionally depends on cdsl_importer, which is
# not present in the standalone runtime distribution. Keep importing the
# executable session runtime possible; callers of the legacy façade get a
# precise import error when they invoke it.
if exc.name != "cdsl_importer":
raise
def _legacy_rebuild_unavailable(*_args, _missing=exc, **_kwargs):
raise RuntimeError("legacy rebuild facade requires the optional cdsl_importer package") from _missing
compare_with_gold = _legacy_rebuild_unavailable
compile_cdsl_to_pack = _legacy_rebuild_unavailable
run_cdsl_only = _legacy_rebuild_unavailable
run_engine = _legacy_rebuild_unavailable
run_rebuild = _legacy_rebuild_unavailable
from .semantic_validation import validate_semantic_cdsl
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl
+454 -76
View File
@@ -20,11 +20,20 @@ from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
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.verifier import RegistryVerifierExecutor
from app.cad_agent.application.capabilities import cached_model_capability, conformance_hash, conformance_tools, verify_model_capability
from app.cad_agent.application.action_handlers import ActionCommandHandler
from app.cad_agent.application.llm_contracts import (
RequirementsAuthorOutput,
CandidateReview,
CompiledRequirementsSpec,
MarkdownDocument,
NextAction,
StatelessCandidateReview,
requirements_spec_schema,
StatelessGeometryConclusion,
canonical_validate,
canonical_validate_schema,
compiled_requirements_schema,
sanitize_compiled_requirements_arguments,
stateless_final_review_schema,
stateless_next_action_schema,
stateless_rollback_checkpoint_schema,
@@ -34,7 +43,7 @@ from app.cad_agent.application.requirements import RequirementsCommandHandler
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment
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
@@ -63,20 +72,48 @@ def settings(root: Path) -> Settings:
)
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."},
}],
}],
})
def requirements_document() -> MarkdownDocument:
return MarkdownDocument(markdown="""# Design Understanding
Simple functional flange.
# Explicit User Requirements
- Create a simple flange.
# Engineering Defaults and Assumptions
- Use a circular body, central through bore, and four equally spaced mounting holes.
# Dimensions and Coordinate Convention
- Units are mm. The body is diameter 100 and thickness 10; bore diameter 30; four holes diameter 10 on radius 35.
# Open Uncertainties
- None.
""")
def completion_target() -> MarkdownDocument:
return MarkdownDocument(markdown="""# Completion Target
- [ ] One connected cylindrical flange body, 100 mm outer diameter and 10 mm thickness.
- [ ] Centered 30 mm through bore.
- [ ] Four 10 mm mounting holes on a circular pattern of 35 mm pitch radius.
""")
def compiled_flange() -> CompiledRequirementsSpec:
return CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "single_connected_body", "expected": {}}, {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "tolerance_mm": 0.1}}, {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"count": 4, "diameter_mm": 10, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
]})
def modeling_plan() -> MarkdownDocument:
return MarkdownDocument(markdown="# Modeling Plan\n\n1. Create the circular flange body.\n2. Cut the centered bore.\n3. Add the circular mounting-hole pattern.\n")
def walk_keys(value: object) -> set[str]:
@@ -94,36 +131,62 @@ def walk_keys(value: object) -> set[str]:
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")
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0)
document = transition(state, "requirements_document_written", requirements_document_path="requirements.md")
target = transition(document, "completion_target_written", completion_target_path="completion-target.md")
compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json")
approved = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md")
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)
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0)
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements")
self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements_document")
resumed = transition(waiting, retry_resume_event(waiting) or "")
self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS)
self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT)
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()),
MarkdownDocument.model_json_schema(),
compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 2),
stateless_next_action_schema(list(runtime.supported_atomic_ids())),
StatelessCandidateReview.model_json_schema(),
StatelessGeometryConclusion.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",
"draft_id", "attachment_id", "record_ids",
}
for schema in schemas:
self.assertFalse(walk_keys(schema) & forbidden, walk_keys(schema) & forbidden)
def test_compiled_requirements_ignores_non_executable_extra_fields(self) -> None:
schema = compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)
raw = json.dumps({
"assumptions": ["top-level notes from the compiler are not executable"],
"requirements": [{
"statement": "model-added copy of the checklist text",
"assumptions": [],
"acceptance_claims": [{
"claim_kind": "single_connected_body",
"expected": {},
"evidence": "not part of the compiler contract",
}],
}],
})
sanitized = sanitize_compiled_requirements_arguments(raw)
self.assertIsInstance(sanitized, str)
self.assertIsNone(canonical_validate_schema(sanitized, schema))
parsed = canonical_validate(sanitized, CompiledRequirementsSpec)
self.assertIsInstance(parsed, CompiledRequirementsSpec)
self.assertEqual(parsed.requirements[0].acceptance_claims[0].claim_kind, "single_connected_body")
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"]})
@@ -133,28 +196,363 @@ class CadV3ProtocolTests(unittest.TestCase):
selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"]
self.assertEqual(selector, {"enum": ["selector_one"]})
def test_requirements_spec_freezes_once_and_preserves_user_value(self) -> None:
def test_root_extrusion_schema_fixes_world_xy_datum_without_deciding_z(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
contract = runtime.operation_contract("extrude_add_blind")
fragment = {
"sketch": {"workplane": {"origin_mm": [0, -6, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 60}},
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 12}},
}
self.assertTrue(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True))
fragment["sketch"]["workplane"]["origin_mm"] = [0, 0, -6]
self.assertEqual(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True), [])
def test_runtime_keeps_executable_feature_prefix_when_later_feature_is_invalid(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "partial_rebuild",
"geometry": {"sketches": [{
"id": "sketch_001",
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
}]},
"features": [
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"},
{"id": "feature_002", "atomic_id": "not_an_engine_operation", "params": {}, "depends_on": ["feature_001"]},
],
}
rebuilt, failures = runtime.rebuild_best_effort(cdsl, str(Path(temporary) / "candidate"), "partial_rebuild", "candidate")
self.assertEqual(rebuilt["executed_feature_ids"], ["feature_001"])
self.assertEqual(len(failures), 1)
self.assertEqual(failures[0]["feature_id"], "feature_002")
def test_action_submission_keeps_partial_feature_batch_for_review(self) -> None:
class PassingVerifier:
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
return [
{
"claim_id": str(claim.get("claim_id") or ""),
"claim_kind": str(claim.get("claim_kind") or ""),
"deterministic": True,
"status": "pass",
"evidence": {},
}
for claim in claims
]
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, PassingVerifier())
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a flange.")
artifacts.initialize_task(task_id, "Create a flange.")
requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
state = repository.get_state(task_id)
proposal = NextAction(
working_head=state.working_head,
intent="Create a two-feature batch.",
requirement_ids=["req_001"],
atomic_id="extrude_add_blind",
expected_change="Keep the executable part of the batch.",
)
self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted)
fragment = {
"sketch": {
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
},
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
}
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "partial_batch",
"geometry": {"sketches": []},
"features": [
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": []},
{"id": "feature_002", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": ["feature_001"]},
],
}
audit = {
"schema_version": "cad.v3.fragment-audit.v1",
"atomic_id": "extrude_add_blind",
"fragment_hash": "hash",
"contract_hash": runtime.operation_contract("extrude_add_blind")["contract_hash"],
"assigned_feature_ids": ["feature_001", "feature_002"],
"assigned_sketch_ids": [],
"selector_snapshot_id": "",
"selector_tokens": [],
"reference_snapshot_id": "",
"reference_tokens": [],
}
rebuilt = {
"executed_feature_ids": ["feature_001"],
"health": {"solid_count": 1},
"topology": {"records": []},
"report": {},
"render_manifest": {},
"paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json"},
}
operation_failures = [{"feature_index": 1, "feature_id": "feature_002", "message": "failed after feature_001"}]
with patch.object(runtime, "materialize_fragment", return_value=(cdsl, audit)), patch.object(runtime, "rebuild_best_effort", return_value=(rebuilt, operation_failures)):
result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment")
self.assertIsInstance(result, Accepted)
reviewing = repository.get_state(task_id)
self.assertEqual(reviewing.phase, TaskPhase.CANDIDATE_REVIEW)
candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {}
self.assertEqual(candidate["executed_feature_ids"], ["feature_001"])
self.assertEqual(candidate["operation_failures"], operation_failures)
def test_best_effort_transition_completes_from_an_executable_checkpoint(self) -> None:
state = TaskState("cad_123456abcdef", TaskPhase.AWAITING_ACTION, 7, active_revision="rev_001", repair_required=True)
completed = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False)
self.assertEqual(completed.phase, TaskPhase.COMPLETED)
self.assertFalse(completed.repair_required)
def test_review_rejection_publishes_the_executable_checkpoint_for_repair(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"
repository.create_task(task_id, "Create a simple flange.")
artifacts.initialize_task(task_id, "Create a simple flange.")
requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
state = repository.get_state(task_id)
proposal = NextAction(
working_head=state.working_head,
intent="Create the base body.",
requirement_ids=["req_001"],
atomic_id="extrude_add_blind",
expected_change="Create the circular body.",
)
self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted)
fragment = {
"sketch": {
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 50},
},
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 10}},
}
self.assertIsInstance(actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment"), Accepted)
reviewing = repository.get_state(task_id)
candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {}
review = CandidateReview(
candidate_id=reviewing.candidate_id,
working_head=reviewing.pending_action.working_head,
verdict="reject",
claim_coverage=[
{"claim_id": str(item["claim_id"]), "status": str(item["status"]), "evidence_refs": []}
for item in candidate["claim_results"]
],
evidence=["Base body is executable."],
issues=["The bore and bolt holes remain to be added."],
)
result = actions.record_candidate_review(task_id, review, invocation_id="review")
self.assertIsInstance(result, Accepted)
self.assertEqual(result.payload["status"], "accepted_with_issues")
published = repository.get_state(task_id)
self.assertEqual(published.phase, TaskPhase.AWAITING_ACTION)
self.assertEqual(published.active_revision, "rev_001")
self.assertTrue(published.repair_required)
completed = actions.finalize_best_effort(task_id, reason=ErrorCode.NO_PROGRESS_LIMIT, invocation_id="best_effort")
self.assertIsInstance(completed, Accepted)
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.COMPLETED)
def test_root_checkpoint_is_not_offered_as_a_rollback_target(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
configured = settings(root)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
runtime = ProfileCadRuntime(configured)
initial = repository.create_task("cad_123456abcdef", "Create a flange.")
document = transition(initial, "requirements_document_written", requirements_document_path="requirements.md")
target = transition(document, "completion_target_written", completion_target_path="completion-target.md")
compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json")
awaiting = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md", repair_required=True)
self.assertTrue(repository.compare_and_swap(document))
self.assertTrue(repository.compare_and_swap(target))
self.assertTrue(repository.compare_and_swap(compiled))
self.assertTrue(repository.compare_and_swap(awaiting, events=[{
"event": "geometry_conclusion",
"decision": "rollback",
"working_head": awaiting.working_head,
}]))
actions = ActionCommandHandler(repository, artifacts, runtime, default_registry())
self.assertFalse(actions.rollback_available(awaiting.task_id))
def test_geometry_conclusion_is_stateless_for_the_author(self) -> None:
schema = StatelessGeometryConclusion.model_json_schema()
self.assertFalse({"working_head", "evidence_refs"} & walk_keys(schema))
def test_outer_cylinder_span_merges_oppositely_oriented_two_sided_faces(self) -> None:
def outer_face(record_id: str, direction: list[float], bbox: list[float]) -> dict[str, object]:
return {
"record_id": record_id,
"geometry": {
"surface_type": "cylinder",
"cylinder_role": "outer",
"radius_mm": 60.0,
"axis_origin_mm": [0.0, 0.0, 0.0],
"axis_direction": direction,
"bbox_mm": bbox,
},
}
facts = {"topology": {"records": [
outer_face("upper", [0.0, 0.0, -1.0], [-60.0, -60.0, 0.0, 60.0, 60.0, 6.0]),
outer_face("lower", [0.0, 0.0, 1.0], [-60.0, -60.0, -6.0, 60.0, 60.0, 0.0]),
]}}
result = default_registry().evaluate(
"outer_cylindrical_surface",
{"diameter_mm": 120.0, "count": 1, "axial_span_mm": 12.0},
facts,
)
self.assertEqual(result["status"], "pass")
self.assertEqual(result["evidence"]["axial_spans_mm"], [12.0])
self.assertEqual(result["evidence"]["tolerance_mm"], 0.1)
def test_compiler_persists_default_tolerance_for_axial_outer_cylinder(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")
repository.create_task(task_id, "Create a flange.")
artifacts.initialize_task(task_id, "Create a flange.")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "axial_span_mm": 10, "count": 1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 10, "count": 4, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
state = repository.get_state(task_id)
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
first_claim = contract["requirements"][0]["acceptance_claims"][0]
self.assertEqual(first_claim["expected"]["tolerance_mm"], 0.1)
def test_record_bound_compiler_claims_are_visualized_before_contract_freeze(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 simple flange.")
artifacts.initialize_task(task_id, "Create a simple flange.")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coaxial", "expected": {"record_ids": ["outer", "bore"], "tolerance": 0.01}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coplanar", "expected": {"record_ids": ["top_face", "bottom_face"], "tolerance_mm": 0.1}}]},
]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
state = repository.get_state(task_id)
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
claims = [claim for requirement in contract["requirements"] for claim in requirement["acceptance_claims"]]
self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "visual"])
self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "visual"])
self.assertTrue(any("coaxial verifier" in warning for warning in contract["verification_warnings"]))
self.assertTrue(any("coplanar verifier" in warning for warning in contract["verification_warnings"]))
def test_local_cylindrical_span_does_not_become_global_bbox_requirement(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 stepped hub adapter.")
artifacts.initialize_task(task_id, "Create a stepped hub adapter.")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, MarkdownDocument(markdown="""# Completion Target
- [ ] A centered solid cylindrical flange body is present with 120 mm outer diameter and 12 mm thickness.
"""), invocation_id="completion_target")
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{
"assumptions": [],
"acceptance_claims": [
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 120, "count": 1, "tolerance_mm": 0.1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.1}},
],
}]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
state = repository.get_state(task_id)
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
claims = contract["requirements"][0]["acceptance_claims"]
self.assertEqual([claim["claim_kind"] for claim in claims], ["outer_cylindrical_surface"])
self.assertEqual(claims[0]["expected"]["axial_span_mm"], 12)
self.assertTrue(any("Global bbox Z verifier" in warning for warning in contract["verification_warnings"]))
def test_requirements_markdown_is_not_rejected_for_missing_headings(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 flange.")
artifacts.initialize_task(task_id, "Create a flange.")
result = handler.submit_requirements_document(task_id, MarkdownDocument(markdown="A simple circular flange with a bore."), invocation_id="plain_markdown")
self.assertIsInstance(result, Accepted)
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_COMPLETION_TARGET)
def test_server_bound_action_accepts_more_than_five_checklist_targets(self) -> None:
action = NextAction(
working_head="cad_123456abcdef:root:v4",
intent="Create the flange body.",
requirement_ids=[f"req_{position:03d}" for position in range(1, 8)],
atomic_id="extrude_add_blind",
expected_change="Add the first solid body.",
)
self.assertEqual(len(action.requirement_ids), 7)
def test_markdown_documents_freeze_before_compiled_flange_contract(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 simple flange.")
artifacts.initialize_task(task_id, "Create a simple flange.")
self.assertIsInstance(handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document"), Accepted)
self.assertIsInstance(handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target"), Accepted)
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile"), Accepted)
self.assertIsInstance(handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan"), 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.assertEqual(len(contract["requirements"]), 3)
claim_kinds = {claim["claim_kind"] for item in contract["requirements"] for claim in item["acceptance_claims"]}
self.assertTrue({"single_connected_body", "through_cylindrical_bore", "circular_hole_pattern"}.issubset(claim_kinds))
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())
self.assertTrue((artifacts.task_dir(task_id) / "modeling-plan.md").is_file())
def test_invalid_verifier_contract_is_rejected_without_state_change(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
@@ -163,44 +561,18 @@ class CadV3ProtocolTests(unittest.TestCase):
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
task_id = "cad_123456abcdef"
initial = repository.create_task(task_id, "Create one solid.")
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")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
invalid = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 0}}]}] * 3})
before = repository.get_state(task_id)
result = handler.submit_compiled_spec(task_id, invalid, 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)
self.assertEqual(repository.get_state(task_id), before)
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:
def test_completion_result_reports_frozen_checklist(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
@@ -209,20 +581,23 @@ class CadV3ProtocolTests(unittest.TestCase):
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")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
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."}]},
claim_results=[{"claim_id": f"claim_{position:03d}", "status": "pass", "evidence": {"measured": True}} for position in range(1, 6)],
review={"visual_claims": []},
)
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)
self.assertIn("Engineering Defaults", requirements)
self.assertIn("Centered 30 mm through bore", target)
self.assertIn("Centered 30 mm through bore.: pass", result)
def test_sqlite_schema_contains_only_current_requirement_paths(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
@@ -231,6 +606,9 @@ class CadV3ProtocolTests(unittest.TestCase):
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("requirements_document_path", columns)
self.assertIn("completion_target_path", columns)
self.assertIn("modeling_plan_path", columns)
self.assertIn("clarification_path", columns)
self.assertNotIn("requirements_draft_path", columns)
self.assertNotIn("requirements_review_path", columns)
@@ -242,7 +620,7 @@ class CadV3ProtocolTests(unittest.TestCase):
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.assertTrue({"write_requirements_document", "write_completion_target", "compile_requirements_spec", "write_modeling_plan"}.issubset(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"})
@@ -336,7 +714,7 @@ class CadV3ProtocolTests(unittest.TestCase):
))
self.assertIsNone(result)
self.assertEqual(verify.await_count, 2)
self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS)
self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT)
events = [queue.get_nowait(), queue.get_nowait()]
self.assertEqual([item[1]["status"] for item in events], ["waiting", "success"])