integrate ganjihong engine refactor

This commit is contained in:
2026-09-09 18:41:56 +08:00
parent cf9cd0366a
commit 21a5185926
116 changed files with 3596 additions and 12259 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
"""Autonomous CAD protocol v3.
"""Single-stage Authoring CDSL protocol.
The package intentionally separates policy from I/O. Delivery code composes
these modules with adapters; it must not bypass the command handlers.
"""
PROTOCOL_VERSION = "3.0"
PROTOCOL_VERSION = "cad.single-stage.v1"
+1 -1
View File
@@ -1 +1 @@
"""Infrastructure adapters for the v3 ports."""
"""Infrastructure adapters for the single-stage ports."""
@@ -1,4 +1,4 @@
"""Immutable v3 file artifacts with staging manifests and atomic publication."""
"""Immutable single-stage artifacts with staging manifests and atomic publication."""
from __future__ import annotations
@@ -8,10 +8,9 @@ import os
from pathlib import Path
import re
import secrets
import shutil
from typing import Any
from app.cad_agent.ports import CandidateStage
from app.cad_agent.ports import StagingRevision
_SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$")
@@ -27,7 +26,7 @@ class FileArtifactStore:
def task_dir(self, task_id: str) -> Path:
if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id):
raise ValueError("Invalid v3 task id")
raise ValueError("Invalid CAD task id")
return self.root / task_id
def artifact_path(self, task_id: str, relative_path: str) -> Path:
@@ -43,7 +42,7 @@ class FileArtifactStore:
) -> None:
root = self.task_dir(task_id)
(root / "documents").mkdir(parents=True, exist_ok=True)
(root / "actions").mkdir(exist_ok=True)
(root / "events").mkdir(exist_ok=True)
(root / "revisions").mkdir(exist_ok=True)
(root / ".staging").mkdir(exist_ok=True)
source = root / "source-requirements.md"
@@ -67,14 +66,14 @@ class FileArtifactStore:
if images:
self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images})
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
"""Mirror committed SQLite events into an append-only JSONL audit log.
SQLite remains authoritative. Replaying this method after an
interruption appends only missing committed sequences and rejects a
divergent line instead of rewriting audit history.
"""
path = self._path(task_id, "actions/action-ledger.jsonl")
path = self._path(task_id, "events/event-ledger.jsonl")
existing: dict[int, dict[str, Any]] = {}
if path.is_file():
for raw in path.read_text(encoding="utf-8").splitlines():
@@ -83,18 +82,18 @@ class FileArtifactStore:
value = json.loads(raw)
sequence = value.get("sequence") if isinstance(value, dict) else None
if not isinstance(sequence, int) or sequence < 1:
raise ValueError("Action ledger contains an invalid sequence")
raise ValueError("Event ledger contains an invalid sequence")
existing[sequence] = value
missing: list[str] = []
for event in events:
sequence = event.get("sequence")
if not isinstance(sequence, int) or sequence < 1:
raise ValueError("Action ledger event has an invalid sequence")
entry = {"schema_version": "cad.action-ledger.v1", **event}
raise ValueError("Event ledger event has an invalid sequence")
entry = {"schema_version": "cad.event-ledger.v1", **event}
previous = existing.get(sequence)
if previous is not None:
if previous != entry:
raise ValueError("Action ledger diverges from committed SQLite event")
raise ValueError("Event ledger diverges from committed SQLite event")
continue
missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
if missing:
@@ -103,7 +102,7 @@ class FileArtifactStore:
handle.writelines(missing)
handle.flush()
os.fsync(handle.fileno())
return "actions/action-ledger.jsonl"
return "events/event-ledger.jsonl"
def write_source_index(
self,
@@ -136,9 +135,8 @@ class FileArtifactStore:
"""Return immutable source paragraphs and attachment blocks.
Source identifiers are assigned only after this method returns, so
callers cannot choose IDs. Attachment metadata is deliberately a
closed, server-provided projection: it lets a reviewer trace the
source without treating upload metadata as arbitrary LLM input.
callers cannot choose IDs. Attachment metadata is a closed,
server-provided projection rather than arbitrary model input.
"""
supplied = source_blocks
if supplied is None:
@@ -195,19 +193,6 @@ class FileArtifactStore:
if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file()
]
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
return self.read_json(task_id, artifact_path or "documents/requirements-spec.json")
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
# State points to the immutable artifact used as program input. The
# fixed name is a read-only convenience view for terminal tasks.
return self.read_json(task_id, artifact_path or "requirements-contract.json")
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str:
if not invocation_id:
return self.write_json_once(task_id, "requirements-contract.json", payload)
return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id)
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None:
path = self._path(task_id, relative_path)
if not path.is_file():
@@ -225,36 +210,20 @@ class FileArtifactStore:
self._write_once(path, text)
return relative_path
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage:
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision:
root = self.task_dir(task_id) / ".staging"
stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20]
directory = root / stable_id
directory.mkdir(parents=True, exist_ok=True)
self._write_json_once(directory / "input.json", payload)
return CandidateStage(stable_id, str(directory.resolve()))
def stage_output_dir(self, task_id: str, stage_id: str) -> str:
return str(self._stage_path(task_id, stage_id, ""))
return StagingRevision(stable_id, str(directory.resolve()))
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str:
path = self._stage_path(task_id, stage_id, relative_path)
self._write_json_once(path, payload)
return relative_path
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None:
path = self._stage_path(task_id, stage_id, relative_path)
if not path.is_file():
return None
value = json.loads(path.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else None
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
source = self._stage_path(task_id, stage_id, "")
target = self._path(task_id, f"revisions/{revision_id}")
if target.exists():
@@ -262,9 +231,8 @@ class FileArtifactStore:
if manifest is None:
raise RuntimeError("Published revision has no valid manifest")
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
# Render/rebuild reports contain paths for the reviewer. Rebase those
# paths while artifacts are still mutable staging output, so they point
# at the revision after the atomic directory rename.
# Rebase report paths while artifacts are still mutable staging output,
# so they point at the revision after the atomic directory rename.
self._rebase_staged_paths(source, target)
manifest = self._create_manifest(source)
self._write_json_once(source / "manifest.json", manifest)
@@ -272,39 +240,6 @@ class FileArtifactStore:
os.replace(source, target)
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None:
"""Find a manifest-verified candidate already renamed before its DB CAS.
Publishing artifacts and advancing SQLite cannot share a transaction.
The stage id persisted inside ``candidate.json`` makes a post-rename
recovery deterministic and prevents another build or revision.
"""
revisions = self.task_dir(task_id) / "revisions"
if not revisions.is_dir():
return None
for revision in sorted(revisions.iterdir()):
if not revision.is_dir() or self._manifest(revision) is None:
continue
candidate_path = revision / "candidate.json"
if not candidate_path.is_file():
continue
try:
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id:
return revision.name, candidate
return None
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None:
root = self.task_dir(task_id) / ".staging"
if not root.is_dir():
return
for directory in root.iterdir():
if not directory.is_dir() or directory.name in referenced_stage_ids:
continue
shutil.rmtree(directory)
def _path(self, task_id: str, relative_path: str) -> Path:
if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts):
raise ValueError("Invalid artifact relative path")
@@ -316,7 +251,7 @@ class FileArtifactStore:
def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path:
if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id):
raise ValueError("Invalid candidate stage id")
raise ValueError("Invalid staging revision id")
root = self.task_dir(task_id).resolve()
stage = (root / ".staging" / stage_id).resolve()
if root not in stage.parents:
@@ -351,13 +286,6 @@ class FileArtifactStore:
temporary.write_bytes(data)
os.replace(temporary, path)
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
if not safe_invocation:
raise ValueError("Artifact invocation ID is invalid")
return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload)
@classmethod
def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None:
cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n")
@@ -371,8 +299,8 @@ class FileArtifactStore:
relative = path.relative_to(directory).as_posix()
files[relative] = sha256(path.read_bytes()).hexdigest()
if not files:
raise RuntimeError("Candidate staging directory has no artifacts")
return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files}
raise RuntimeError("Staging revision has no artifacts")
return {"schema_version": "cad.single-stage.artifact-manifest.v1", "files": files}
@staticmethod
def _manifest(directory: Path) -> dict[str, Any] | None:
@@ -1,191 +0,0 @@
"""Bounded, file-backed author guidance for the CDSL workflow.
The corpus is deliberately non-authoritative: contracts, schemas, topology
tokens, and server preflight always remain the executable source of truth.
Loading errors return an empty selection so authoring continues with the
pre-guidance prompt instead of turning documentation into an availability
dependency.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.cad_agent.domain.state import TaskPhase
from app.cad_agent.ports import AuthorGuidanceSelection
_MIN_CHARS = 1_200
_MAX_CHARS = 6_000
@dataclass(frozen=True, slots=True)
class _Section:
section_id: str
title: str
priority: int
mandatory: bool
content: str
@property
def block(self) -> str:
return f"## {self.title}\n{self.content.strip()}"
@dataclass(frozen=True, slots=True)
class _Corpus:
version: str
sections: dict[str, _Section]
phase_sections: dict[str, tuple[str, ...]]
repair_sections: tuple[str, ...]
final_sections: tuple[str, ...]
operation_sections: dict[str, tuple[str, ...]]
class FileAuthorGuidance:
"""Read and select the checked-in corpus deterministically.
Selection depends exclusively on workflow state and the runtime operation
registry. It intentionally receives neither the user's request nor image
observations, so it cannot become an implicit part-family classifier.
"""
def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None:
self.root = root
self.enabled = enabled
self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars))
self._corpus: _Corpus | None = None
self._load_error = ""
def select(
self,
*,
phase: TaskPhase,
atomic_id: str,
repair_required: bool,
supported_atomic_ids: tuple[str, ...],
) -> AuthorGuidanceSelection:
if not self.enabled:
return AuthorGuidanceSelection(fallback_reason="guidance_disabled")
corpus = self._load()
if corpus is None:
return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable")
supported = set(supported_atomic_ids)
if set(corpus.operation_sections) != supported:
return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch")
if atomic_id and atomic_id not in supported:
return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id")
if repair_required:
requested = list(corpus.repair_sections)
elif phase == TaskPhase.FINAL_VALIDATION:
requested = list(corpus.final_sections)
else:
requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ())))
if atomic_id:
requested.extend(corpus.operation_sections[atomic_id])
requested = list(dict.fromkeys(requested))
if not requested:
return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections")
mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory]
optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory]
optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id)))
selected: list[str] = []
text = ""
for section_id in [*mandatory, *optional]:
block = corpus.sections[section_id].block
candidate = block if not text else f"{text}\n\n{block}"
if len(candidate) <= self.max_chars:
text = candidate
selected.append(section_id)
elif section_id in mandatory:
# Do not silently drop contract, datum, or operation guidance.
return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget")
return AuthorGuidanceSelection(
version=corpus.version,
section_ids=tuple(selected),
content=text,
enabled=True,
)
def _load(self) -> _Corpus | None:
if self._corpus is not None:
return self._corpus
if self._load_error:
return None
try:
manifest_path = self.root / "manifest.json"
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("manifest is not an object")
version = raw.get("version")
if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version:
raise ValueError("manifest version is invalid")
raw_sections = raw.get("sections")
if not isinstance(raw_sections, list) or not raw_sections:
raise ValueError("manifest sections are invalid")
sections: dict[str, _Section] = {}
root = self.root.resolve()
for item in raw_sections:
if not isinstance(item, dict):
raise ValueError("section declaration is invalid")
section_id = item.get("id")
filename = item.get("file")
title = item.get("title")
priority = item.get("priority")
mandatory = item.get("mandatory", False)
if (
not isinstance(section_id, str) or not section_id
or not isinstance(filename, str) or not filename
or not isinstance(title, str) or not title
or not isinstance(priority, int) or isinstance(priority, bool)
or not isinstance(mandatory, bool)
or section_id in sections
):
raise ValueError("section metadata is invalid")
path = (self.root / filename).resolve()
if root not in path.parents or not path.is_file():
raise ValueError("section file is unavailable")
content = path.read_text(encoding="utf-8").strip()
if not content:
raise ValueError("section content is empty")
sections[section_id] = _Section(section_id, title, priority, mandatory, content)
def identifiers(value: Any, field: str) -> tuple[str, ...]:
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value):
raise ValueError(f"{field} is invalid")
return tuple(dict.fromkeys(value))
raw_phases = raw.get("phase_sections")
if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases:
raise ValueError("phase sections are invalid")
phase_sections = {
phase: identifiers(section_ids, f"phase {phase}")
for phase, section_ids in raw_phases.items()
if isinstance(phase, str)
}
if len(phase_sections) != len(raw_phases):
raise ValueError("phase name is invalid")
operation_sections = {
atomic_id: identifiers(section_ids, f"operation {atomic_id}")
for atomic_id, section_ids in (raw.get("operation_sections") or {}).items()
if isinstance(atomic_id, str)
}
if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}):
raise ValueError("operation sections are invalid")
self._corpus = _Corpus(
version=version,
sections=sections,
phase_sections=phase_sections,
repair_sections=identifiers(raw.get("repair_sections"), "repair sections"),
final_sections=identifiers(raw.get("final_sections"), "final sections"),
operation_sections=operation_sections,
)
return self._corpus
except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
self._load_error = f"guidance_load_failed:{type(error).__name__}"
return None
@@ -1,4 +1,4 @@
"""In-process idempotent event delivery for the v3 delivery boundary."""
"""In-process idempotent event delivery for the single-stage protocol."""
from __future__ import annotations
@@ -1,64 +0,0 @@
"""Independent rendered-review adapter for protocol v3."""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
from app.cad_agent.ports import AdapterUnavailable
class RenderedReviewGateway:
def __init__(self, models: StructuredModelGateway) -> None:
self.models = models
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]:
name = str((tool.get("function") or {}).get("name") or "")
if not name:
raise RuntimeError("Review tool is missing a name")
public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"}
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}]
if kind in {"image_observation", "final"}:
for raw_path in payload.get("reference_image_paths") or ():
path = Path(str(raw_path))
if path.is_file():
content.append(self._image_part(path))
if kind in {"candidate", "final"}:
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
for path in self._evidence_paths(manifest):
content.append(self._image_part(path))
return await self.models.call_tool(
messages=[
{"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."},
{"role": "user", "content": content},
],
tool=tool,
provider_id=provider_id,
model_id=model_id,
required_tool_name=name,
)
@staticmethod
def _evidence_paths(manifest: dict[str, Any]) -> list[Path]:
selected: list[Path] = []
contact = Path(str(manifest.get("contact_sheet_path") or ""))
if contact.is_file():
selected.append(contact)
wanted = {"top", "front", "right", "isometric"}
for item in manifest.get("views") or ():
if not isinstance(item, dict) or str(item.get("id") or "") not in wanted:
continue
path = Path(str(item.get("path") or ""))
if path.is_file():
selected.append(path)
if not selected:
raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable")
return selected[:5]
@staticmethod
def _image_part(path: Path) -> dict[str, Any]:
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,8 @@
"""SQLite repository: the sole mutable state authority for protocol v3."""
"""SQLite authority for ``cad.single-stage.v1`` tasks.
There is no reader or migration for the deleted feature-DAG protocol. Opening
a legacy task database drops its task data before creating this schema.
"""
from __future__ import annotations
from contextlib import contextmanager
@@ -10,11 +13,13 @@ from threading import RLock
from typing import Any, Iterator
from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState
from app.cad_agent.domain.state import TaskPhase, TaskState
from app.cad_agent.ports import InvocationRecord
class SqliteTaskRepository:
PROTOCOL_VERSION = "cad.single-stage.v1"
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self.database_path.parent.mkdir(parents=True, exist_ok=True)
@@ -35,271 +40,151 @@ class SqliteTaskRepository:
def _initialize(self) -> None:
with self._lock, self._connection() as connection:
# Protocol 3.2 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.2'" not in str(existing[0] or ""):
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").fetchone()
if existing is not None and self.PROTOCOL_VERSION 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(
"""
# This database is dedicated to CAD task state. An old
# protocol has no safe reader or migration, so remove every
# persisted task table and let SQLite remove their indexes.
tables = connection.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
for row in tables:
name = str(row[0]).replace('"', '""')
connection.execute(f'DROP TABLE IF EXISTS "{name}"')
connection.executescript(f"""
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'),
request TEXT NOT NULL,
phase TEXT NOT NULL,
state_version INTEGER NOT NULL,
active_revision TEXT NOT NULL DEFAULT '',
pending_action_json TEXT,
candidate_id TEXT NOT NULL DEFAULT '',
candidate_stage_id TEXT NOT NULL DEFAULT '',
repair_required INTEGER NOT NULL DEFAULT 0,
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 '',
feature_plan_path TEXT NOT NULL DEFAULT '',
feature_plan_hash TEXT NOT NULL DEFAULT '',
feature_stage_id TEXT NOT NULL DEFAULT '',
protocol_version TEXT NOT NULL CHECK(protocol_version = '{self.PROTOCOL_VERSION}'),
request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL,
active_revision TEXT NOT NULL DEFAULT '', repair_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '',
requirements_path TEXT NOT NULL DEFAULT '', authoring_path TEXT NOT NULL DEFAULT '',
runtime_cdsl_path TEXT NOT NULL DEFAULT '', compile_audit_path TEXT NOT NULL DEFAULT '',
diagnostics_path TEXT NOT NULL DEFAULT '', completion_path TEXT NOT NULL DEFAULT '',
clarification_path TEXT NOT NULL DEFAULT '',
requirements_contract_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS invocations (
invocation_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(task_id),
idempotency_key TEXT NOT NULL,
status TEXT NOT NULL,
result_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(task_id, idempotency_key)
invocation_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id),
idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(task_id,idempotency_key)
);
CREATE TABLE IF NOT EXISTS ledger (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(task_id),
event_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
event_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS outbox (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(task_id),
event_json TEXT NOT NULL,
published_at TEXT
event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
event_json TEXT NOT NULL, published_at TEXT
);
CREATE TABLE IF NOT EXISTS usage_records (
usage_id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(task_id),
usage_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
usage_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
usage_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tool_audits (
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(task_id),
audit_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
audit_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
audit_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS model_capabilities (
provider_id TEXT NOT NULL,
model_id TEXT NOT NULL,
schema_hash TEXT NOT NULL,
supported INTEGER NOT NULL,
report_json TEXT NOT NULL,
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(provider_id, model_id, schema_hash)
provider_id TEXT NOT NULL, model_id TEXT NOT NULL, schema_hash TEXT NOT NULL,
supported INTEGER NOT NULL, report_json TEXT NOT NULL, checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(provider_id,model_id,schema_hash)
);
"""
)
""")
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.2', ?, ?, 0)",
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
"INSERT OR IGNORE INTO tasks(task_id,protocol_version,request,phase,state_version) VALUES (?,?,?,?,0)",
(task_id, self.PROTOCOL_VERSION, request, TaskPhase.ANALYZING_REQUEST.value),
)
state = self.get_state(task_id)
if state is None:
raise RuntimeError("SQLite task insert was not visible")
raise RuntimeError("Task was not persisted")
return state
def get_state(self, task_id: str) -> TaskState | None:
with self._lock, self._connection() as connection:
row = connection.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
return self._state(row) if row is not None else None
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
return self._state(row) if row else None
def get_task_projection(self, task_id: str) -> dict[str, Any] | None:
state = self.get_state(task_id)
if state is None:
return None
events = self.ledger_events(task_id)
revisions = [
{
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
}
for item in events
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str)
]
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 []
status_event = next((
item for item in reversed(events)
if item.get("event") in {
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted",
"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.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 []
# A later repair can end at authoring or compilation after an earlier
# build already published an executable prefix. Its terminal event
# intentionally contains diagnostics rather than a duplicate path
# map, so keep the most recent event that actually delivered a
# revision. Otherwise the completed projection loses the existing
# STEP/GLB links exactly when best-effort publication matters most.
result: dict[str, Any] = {}
paths: dict[str, Any] = {}
for item in reversed(events):
if item.get("event") not in {
"build_completed", "published_best_effort", "build_failed",
}:
continue
candidate_paths = item.get("paths")
if isinstance(candidate_paths, dict) and candidate_paths:
result, paths = item, candidate_paths
break
return {
"schema_version": "3.2",
"task_id": state.task_id,
"phase": state.phase.value,
"lifecycle": self._lifecycle(state.phase),
"state_version": state.version,
"active_revision": state.active_revision,
"current_revision": state.active_revision,
"schema_version": self.PROTOCOL_VERSION, "task_id": task_id, "phase": state.phase.value,
"lifecycle": self._lifecycle(state.phase), "state_version": state.version,
"active_revision": state.active_revision, "current_revision": state.active_revision,
"published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "",
"pending_action": self._pending_payload(state.pending_action),
"active_candidate_id": state.candidate_id,
"repair_required": state.repair_required,
"repair_count": state.repair_count, "repair_budget": 2,
"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,
"feature_plan_path": state.feature_plan_path,
"feature_plan_hash": state.feature_plan_hash,
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "",
"pending_feature": self._pending_payload(state.pending_feature),
"feature_nodes": self._feature_node_projection(events),
"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 or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED)
else "verified" if state.phase == TaskPhase.COMPLETED
else "pending"
),
"verification_warnings": verification_warnings,
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
"questions": questions,
"issues": issues,
"blocker_type": (
"requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER
else str(status_event.get("event") or "") if isinstance(status_event, dict) else ""
),
"user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions),
"action_ledger_summary": events[-12:],
"revisions": revisions,
"requirements_path": state.requirements_path, "authoring_path": state.authoring_path,
"runtime_cdsl_path": state.runtime_cdsl_path, "compile_audit_path": state.compile_audit_path,
"diagnostics_path": state.diagnostics_path, "completion_path": state.completion_path,
"message": str(result.get("message") or ""),
"revisions": ([{
"revision_id": state.active_revision, "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED else "checkpoint",
"cdsl_path": paths.get("model.cdsl.json", ""), "step_path": paths.get("model.step", ""),
"glb_path": paths.get("model.glb", ""), "report_path": paths.get("rebuild-report.json", ""),
}] if state.active_revision else []),
"action_ledger_summary": events[-16:],
}
@staticmethod
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
nodes: dict[str, dict[str, Any]] = {}
for event in events:
node_id = str(event.get("node_id") or "")
if not node_id:
continue
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
if event.get("event") == "feature_node_scheduled":
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
elif event.get("event") == "feature_node_failed":
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
elif event.get("event") == "feature_node_verified":
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
item["status"] = "invalidated"
return list(nodes.values())
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
with self._lock, self._connection() as connection:
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall()
return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows]
rows = connection.execute("SELECT sequence,event_json,created_at FROM ledger WHERE task_id=? ORDER BY sequence", (task_id,)).fetchall()
return [{"sequence": int(row["sequence"]), "at": str(row["created_at"]), **json.loads(row["event_json"])} for row in rows]
def invocation_records(self, task_id: str) -> list[dict[str, Any]]:
with self._lock, self._connection() as connection:
rows = connection.execute(
"SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id",
(task_id,),
).fetchall()
return [
{
"invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]),
"status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None,
"created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]),
}
for row in rows
]
def compare_and_swap(
self,
state: TaskState,
*,
events: list[dict[str, Any]] = (),
invocation_id: str | None = None,
invocation_result: dict[str, Any] | None = None,
) -> bool:
def compare_and_swap(self, state: TaskState, *, events: list[dict[str, Any]] = (), invocation_id: str | None = None, invocation_result: dict[str, Any] | None = None) -> bool:
if (invocation_id is None) != (invocation_result is None):
raise ValueError("Invocation completion requires both invocation_id and invocation_result")
previous_version = state.version - 1
if previous_version < 0:
raise ValueError("State version must advance exactly once")
raise ValueError("Invocation completion requires both values")
previous = state.version - 1
with self._lock, self._connection() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
cursor = connection.execute(
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
WHERE task_id = ? AND state_version = ?""",
(
state.phase.value, state.version, state.active_revision,
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None,
state.candidate_id, state.candidate_stage_id,
int(state.repair_required), state.last_error.value if state.last_error else None,
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
state.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
state.clarification_path, state.requirements_contract_path,
state.task_id, previous_version,
),
)
cursor = connection.execute("""
UPDATE tasks SET phase=?,state_version=?,active_revision=?,repair_count=?,last_error=?,retry_from_phase=?,
requirements_path=?,authoring_path=?,runtime_cdsl_path=?,compile_audit_path=?,diagnostics_path=?,completion_path=?,clarification_path=?,updated_at=CURRENT_TIMESTAMP
WHERE task_id=? AND state_version=?
""", (
state.phase.value, state.version, state.active_revision, state.repair_count,
state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "",
state.requirements_path, state.authoring_path, state.runtime_cdsl_path, state.compile_audit_path,
state.diagnostics_path, state.completion_path, state.clarification_path, state.task_id, previous,
))
if cursor.rowcount != 1:
connection.execute("ROLLBACK")
return False
for event in events:
encoded = json.dumps(event, ensure_ascii=True, sort_keys=True)
connection.execute("INSERT INTO ledger(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
if invocation_id is not None and invocation_result is not None:
finished = connection.execute(
"""UPDATE invocations
SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP
WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""",
(json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id),
)
if finished.rowcount != 1:
raise ValueError("Invocation is not an active record for this state transition")
for item in events:
encoded = json.dumps(item, ensure_ascii=True, sort_keys=True)
connection.execute("INSERT INTO ledger(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
if invocation_id is not None:
updated = connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND task_id=? AND status='processing'", (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id))
if updated.rowcount != 1:
raise ValueError("Invocation is not active")
connection.execute("COMMIT")
return True
except Exception:
@@ -310,144 +195,92 @@ class SqliteTaskRepository:
with self._lock, self._connection() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone()
if existing is not None:
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND idempotency_key=?", (task_id, idempotency_key)).fetchone()
if row is None:
connection.execute("INSERT INTO invocations(invocation_id,task_id,idempotency_key,status) VALUES (?,?,?,'processing')", (invocation_id, task_id, idempotency_key))
connection.execute("COMMIT")
return self._invocation(existing)
connection.execute("INSERT INTO invocations(invocation_id, task_id, idempotency_key, status) VALUES (?, ?, ?, 'processing')", (invocation_id, task_id, idempotency_key))
return InvocationRecord(invocation_id, idempotency_key, "processing")
connection.execute("COMMIT")
return InvocationRecord(invocation_id, idempotency_key, "processing")
return self._invocation(row)
except Exception:
connection.execute("ROLLBACK")
raise
def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None:
with self._lock, self._connection() as connection:
row = connection.execute(
"SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?",
(task_id, invocation_id),
).fetchone()
return self._invocation(row) if row is not None else None
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND invocation_id=?", (task_id, invocation_id)).fetchone()
return self._invocation(row) if row else None
def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None:
with self._lock, self._connection() as connection:
row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone()
if row is None:
raise ValueError("Unknown invocation")
if str(row["status"]) == "finished":
# A winning concurrent command has already recorded the only
# durable result for this idempotency key. Never overwrite it.
return
cursor = connection.execute(
"UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'",
(json.dumps(result, ensure_ascii=True), invocation_id),
)
if cursor.rowcount != 1:
raise ValueError("Invocation could not be completed")
connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND status='processing'", (json.dumps(result, ensure_ascii=True), invocation_id))
def append_outbox(self, task_id: str, event: dict[str, Any]) -> None:
with self._lock, self._connection() as connection:
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]:
query = "SELECT event_id,task_id,event_json FROM outbox WHERE published_at IS NULL"
args: tuple[Any, ...] = ()
if task_id is not None:
query += " AND task_id=?"
args += (task_id,)
query += " ORDER BY event_id LIMIT ?"
args += (limit,)
with self._lock, self._connection() as connection:
if task_id is None:
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall()
else:
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall()
return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows]
rows = connection.execute(query, args).fetchall()
return [{"event_id": int(row["event_id"]), "task_id": str(row["task_id"]), **json.loads(row["event_json"])} for row in rows]
def mark_outbox_published(self, event_id: int) -> None:
with self._lock, self._connection() as connection:
connection.execute("UPDATE outbox SET published_at = CURRENT_TIMESTAMP WHERE event_id = ? AND published_at IS NULL", (event_id,))
connection.execute("UPDATE outbox SET published_at=CURRENT_TIMESTAMP WHERE event_id=? AND published_at IS NULL", (event_id,))
def record_usage(self, task_id: str, payload: dict[str, Any]) -> None:
with self._lock, self._connection() as connection:
connection.execute("INSERT INTO usage_records(task_id, usage_json) VALUES (?, ?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
connection.execute("INSERT INTO usage_records(task_id,usage_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None:
"""Persist a diagnostic structured-output audit separately from usage."""
with self._lock, self._connection() as connection:
connection.execute(
"INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)",
(task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)),
)
connection.execute("INSERT INTO tool_audits(task_id,audit_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
def tool_audits(self, task_id: str) -> list[dict[str, Any]]:
with self._lock, self._connection() as connection:
rows = connection.execute(
"SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id",
(task_id,),
).fetchall()
return [
{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])}
for row in rows
]
rows = connection.execute("SELECT audit_id,audit_json,created_at FROM tool_audits WHERE task_id=? ORDER BY audit_id", (task_id,)).fetchall()
return [{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} for row in rows]
def usage_summary(self, task_id: str) -> dict[str, Any]:
with self._lock, self._connection() as connection:
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id = ? ORDER BY usage_id", (task_id,)).fetchall()
values = [json.loads(row["usage_json"]) for row in rows]
return {
"calls": len(values),
"prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)),
"completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)),
"context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)),
"records": values,
}
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id=? ORDER BY usage_id", (task_id,)).fetchall()
return {"calls": len(rows), "records": [json.loads(row["usage_json"]) for row in rows]}
def running_task_ids(self) -> list[str]:
phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY})
placeholders = ", ".join("?" for _ in phases)
terminal = tuple(item.value for item in (TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER))
with self._lock, self._connection() as connection:
rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall()
rows = connection.execute("SELECT task_id FROM tasks WHERE phase NOT IN (?,?,?,?) ORDER BY created_at", terminal).fetchall()
return [str(row["task_id"]) for row in rows]
def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None:
with self._lock, self._connection() as connection:
row = connection.execute("SELECT supported, report_json, checked_at FROM model_capabilities WHERE provider_id = ? AND model_id = ? AND schema_hash = ?", (provider_id, model_id, schema_hash)).fetchone()
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None
row = connection.execute("SELECT supported,report_json,checked_at FROM model_capabilities WHERE provider_id=? AND model_id=? AND schema_hash=?", (provider_id, model_id, schema_hash)).fetchone()
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": str(row["checked_at"])} if row else None
def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None:
with self._lock, self._connection() as connection:
connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(provider_id, model_id, schema_hash) DO UPDATE SET supported = excluded.supported, report_json = excluded.report_json, checked_at = CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
connection.execute("""INSERT INTO model_capabilities(provider_id,model_id,schema_hash,supported,report_json) VALUES (?,?,?,?,?)
ON CONFLICT(provider_id,model_id,schema_hash) DO UPDATE SET supported=excluded.supported,report_json=excluded.report_json,checked_at=CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
@staticmethod
def _state(row: sqlite3.Row) -> TaskState:
raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None
pending = PendingAction(
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
) if isinstance(raw_pending, dict) else None
return TaskState(
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
active_revision=str(row["active_revision"] or ""), pending_action=pending,
candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""),
repair_required=bool(row["repair_required"]),
active_revision=str(row["active_revision"]), repair_count=int(row["repair_count"]),
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 ""),
feature_plan_path=str(row["feature_plan_path"] or ""),
feature_plan_hash=str(row["feature_plan_hash"] or ""),
feature_stage_id=str(row["feature_stage_id"] or ""),
clarification_path=str(row["clarification_path"] or ""),
requirements_contract_path=str(row["requirements_contract_path"] or ""),
requirements_path=str(row["requirements_path"]), authoring_path=str(row["authoring_path"]),
runtime_cdsl_path=str(row["runtime_cdsl_path"]), compile_audit_path=str(row["compile_audit_path"]),
diagnostics_path=str(row["diagnostics_path"]), completion_path=str(row["completion_path"]), clarification_path=str(row["clarification_path"]),
)
@staticmethod
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
if pending is None:
return None
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
@staticmethod
def _invocation(row: sqlite3.Row) -> InvocationRecord:
return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None)
@@ -460,6 +293,6 @@ class SqliteTaskRepository:
return "failed"
if phase == TaskPhase.CANCELLED:
return "cancelled"
if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}:
return phase.value.lower()
if phase == TaskPhase.WAITING_FOR_USER:
return "waiting_for_user"
return "running"
@@ -1,4 +1,4 @@
"""The sole OpenAI-compatible structured-output adapter for protocol v3.
"""The sole OpenAI-compatible structured-output adapter for Authoring CDSL.
Raw tool arguments are intentionally preserved. Callers must run their
Pydantic/JSON-Schema canonical validator before causing any state transition.
@@ -170,39 +170,12 @@ class StructuredModelGateway:
@staticmethod
def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]:
"""Give capability probes the same selector fact production exposes.
A required opaque selector is not inferable from a generic request for
a minimal example. Production fragment turns expose a topology token
before the fragment tool, so the probe must do the same while still
letting the provider generate every other schema field itself.
"""
"""Probe the two standalone structured documents used by this protocol."""
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
name = str(function.get("name") or "")
parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {}
feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None
feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {}
feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else []
selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict)
instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative."
if selector_required:
atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..."
instruction += (
" The current topology exposes opaque selector token `sel_conformance`. "
f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)},"
"\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. "
"Because feature.selector_tokens is required, it is author input and must not be moved into params."
)
if name == "review_candidate":
instruction += (
" This is a candidate-review probe: include every required top-level and nested field; "
"in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them."
)
elif name == "review_final":
instruction += (
" This is a final-review probe: include every required top-level and nested field; "
"in particular provide a verdict and claim_coverage. Use pass only where the schema permits it."
)
if name == "write_authoring_cdsl":
instruction += " Return cad.author.v1 with document-local names only; do not include runtime IDs, stable selectors, snapshots, or selector tokens."
return [{"role": "system", "content": instruction}]
def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]:
@@ -256,7 +229,7 @@ class StructuredModelGateway:
force_tool_name: bool = True,
) -> dict[str, Any]:
if len(tools) != 1 or not required_tool_name:
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
raise StructuredModelError("The Authoring protocol requires exactly one named tool per provider call.")
request_options = provider.request_options if include_reasoning else {}
if provider.api_style == "responses":
payload: dict[str, Any] = {
@@ -1,19 +0,0 @@
"""Adapter exposing the pure verifier registry through the application port."""
from __future__ import annotations
from typing import Any
from app.cad_agent.domain.verifier_registry import VerifierRegistry
class RegistryVerifierExecutor:
def __init__(self, registry: VerifierRegistry) -> None:
self.registry = registry
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
for claim in claims:
result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts)
results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result})
return results
@@ -1 +1 @@
"""Application command handlers and context assembly for protocol v3."""
"""Application workflow and compiler for the Authoring CDSL protocol."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
"""Compile model-facing Authoring CDSL into server-owned runtime CDSL."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any, Callable
from jsonschema import Draft202012Validator
from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code
class AuthoringCompileError(ValueError):
def __init__(self, code: str, message: str, *, path: str = "") -> None:
self.code, self.path = code, path
super().__init__(message)
class AuthoringCompiler:
def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None:
self.operation_contract = operation_contract
self.version = version
def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
try:
validate_finite(raw)
doc = AuthoringDocument.model_validate(raw)
except Exception as error:
raise AuthoringCompileError(validation_error_code(error), str(error)) from error
source_features = [feature for body in doc.bodies for feature in body.features]
dependencies, implicit_selector_dependencies = self._effective_dependencies(doc)
ordered = self._topological(doc, dependencies)
# Identity follows the source document, while execution follows the
# dependency graph. A dependency reordering must never renumber IDs.
feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)}
body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)}
features: list[dict[str, Any]] = []
sketches: list[dict[str, Any]] = []
sketch_index = 0
source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)}
sketch_ids_by_feature = {
feature.name: f"sketch_{source_positions[feature.name]:03d}"
for feature in source_features
if feature.sketch is not None
}
for feature in ordered:
try:
contract = self.operation_contract(feature.operation)
except Exception as error:
raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error
params = self._references(
deepcopy(feature.params), contract, feature_ids, feature.name,
sketch_ids_by_feature=sketch_ids_by_feature,
)
self._validate_params(contract, params, feature.name)
selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors]
selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}
fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"}
required_selectors = fragment_shape["selector_tokens"] == "required"
runtime_feature_selectors: list[dict[str, Any]] = []
if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]:
raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors")
if not required_selectors and selectors:
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors")
if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors):
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors")
if required_selectors:
slot = str(selector_policy["slot"])
if slot == "feature.selectors":
runtime_feature_selectors = selectors
elif slot.startswith("params."):
parameter = slot.removeprefix("params.")
if "." in parameter:
raise AuthoringCompileError(
"OPERATION_UNSUPPORTED",
f"runtime has no safe selector binding for {slot}",
path=f"features.{feature.name}.selectors",
)
if parameter in params:
raise AuthoringCompileError(
"AUTHOR_FORBIDDEN_FIELD",
f"{parameter} is server-injected from selectors",
path=f"features.{feature.name}.params.{parameter}",
)
params[parameter] = selectors[0] if len(selectors) == 1 else selectors
else:
raise AuthoringCompileError(
"OPERATION_UNSUPPORTED",
f"runtime has no safe selector binding for {slot}",
path=f"features.{feature.name}.selectors",
)
output = {
"id": feature_ids[feature.name], "atomic_id": feature.operation,
"depends_on": [feature_ids[name] for name in dependencies[feature.name]],
"params": params,
**({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}),
}
needs_sketch = fragment_shape["sketch"] == "required"
if needs_sketch and feature.sketch is None:
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch")
if not needs_sketch and feature.sketch is not None:
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch")
if feature.sketch is not None:
sketch_index += 1
sketch_id = f"sketch_{source_positions[feature.name]:03d}"
sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))})
output["sketch_id"] = sketch_id
features.append(output)
runtime = {
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
"part_id": "compiled", "meta": {"unit": "mm"},
"bodies": [
{"id": body_ids[body.name], "name": body.name}
for body in doc.bodies
],
"geometry": {"sketches": sketches}, "features": features,
}
digest = sha256(self._canonical_json(raw)).hexdigest()
return runtime, {
"schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version,
"source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids,
"sketch_ids": [item["id"] for item in sketches],
"implicit_selector_dependencies": implicit_selector_dependencies,
}
@staticmethod
def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""Make every declared selector source an auditable graph dependency.
A selector is already an explicit local source reference. Requiring the
author to repeat that same edge in a second field only creates a
formatting failure; it does not add CAD intent. The compiler therefore
adds the direct source edge deterministically and records it in the
audit. It never selects a substitute topology element.
"""
by_name = {item.name: item for body in doc.bodies for item in body.features}
dependencies: dict[str, list[str]] = {}
implicit: dict[str, list[str]] = {}
for feature in by_name.values():
values = list(feature.depends_on)
additions: list[str] = []
for selector in feature.selectors:
source = selector.source.split(".", 1)[0]
if source not in by_name:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID",
f"unknown selector source: {selector.source}",
path=f"features.{feature.name}.selectors",
)
if source not in values:
values.append(source)
additions.append(source)
dependencies[feature.name] = values
if additions:
implicit[feature.name] = additions
return dependencies, implicit
@staticmethod
def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]:
by_name = {item.name: item for body in doc.bodies for item in body.features}
result: list[Any] = []
visiting: set[str] = set()
done: set[str] = set()
def visit(name: str) -> None:
if name in visiting:
raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}")
if name in done:
return
visiting.add(name)
for dependency in dependencies[name]:
visit(dependency)
visiting.remove(name); done.add(name); result.append(by_name[name])
for body in doc.bodies:
for feature in body.features:
visit(feature.name)
return result
@staticmethod
def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]:
source = selector.source.split(".", 1)[0]
if source not in feature_ids:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}")
source_feature = next(item for item in source_features if item.name == source)
role = selector.source.split(".", 1)[1]
role = AuthoringCompiler._runtime_role(role, source_feature.operation)
return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match}
@staticmethod
def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]:
"""Lower the small Authoring sketch language to the generic runtime form."""
profile = sketch["profile"]
if profile["type"] == "circle":
profile = {
"type": "circle",
"center": profile["center_mm"],
"radius_mm": float(profile["diameter_mm"]) / 2.0,
}
return {"workplane": sketch["workplane"], "profile": profile}
@staticmethod
def _runtime_role(role: str, operation: str) -> str:
if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}:
return role
family = (
"extrude" if operation.startswith("extrude") else
"sweep" if operation.startswith("sweep") else
"loft" if operation.startswith("loft") else
"cylinder" if operation == "cylinder_add" else ""
)
if role in {"top_planar_face", "end_face"} and family:
return family + ".end"
if role in {"bottom_planar_face", "start_face"} and family:
return family + ".start"
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}")
@staticmethod
def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None:
schema = contract.get("author_params_schema") or {"type": "object"}
errors = list(Draft202012Validator(schema).iter_errors(params))
if errors:
first = errors[0]
path = ".".join(str(item) for item in first.absolute_path)
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip("."))
@staticmethod
def _references(
params: dict[str, Any],
contract: dict[str, Any],
feature_ids: dict[str, str],
feature_name: str,
*,
sketch_ids_by_feature: dict[str, str],
) -> dict[str, Any]:
params = AuthoringCompiler._rewrite_named_references(
params, feature_ids, sketch_ids_by_feature, feature_name,
)
policy = contract.get("reference_policy") or {"mode": "none"}
if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors":
return params
key = str(policy["slot"]).removeprefix("params.")
value = params.get(key)
if isinstance(value, list):
names = value
elif value is None and policy["min_items"] == 0:
names = []
elif isinstance(value, str):
names = [value]
else:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}")
runtime_ids = set(feature_ids.values())
if not all(isinstance(item, str) and item in runtime_ids for item in names):
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}")
if not policy["min_items"] <= len(names) <= policy["max_items"]:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}")
return params
@staticmethod
def _rewrite_named_references(
value: Any,
feature_ids: dict[str, str],
sketch_ids_by_feature: dict[str, str],
consuming_feature: str,
key: str = "",
) -> Any:
if isinstance(value, dict):
return {
item_key: AuthoringCompiler._rewrite_named_references(
item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key,
)
for item_key, item_value in value.items()
}
if isinstance(value, list):
return [
AuthoringCompiler._rewrite_named_references(
item, feature_ids, sketch_ids_by_feature, consuming_feature, key,
)
for item in value
]
if not isinstance(value, str):
return value
if key == "profile_sketch_ids":
if value not in sketch_ids_by_feature:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}",
path=f"features.{consuming_feature}.params.{key}",
)
return sketch_ids_by_feature[value]
if key.endswith("_feature_id") or key.endswith("_feature_ids"):
if value not in feature_ids:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}",
path=f"features.{consuming_feature}.params.{key}",
)
return feature_ids[value]
return value
@staticmethod
def _canonical_json(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
@@ -0,0 +1,169 @@
"""Strict model-facing Authoring CDSL contract.
This is intentionally separate from the runtime CDSL: model output contains
only document-local names and declarative references. Runtime identities are
allocated by :mod:`authoring_compiler`.
"""
from __future__ import annotations
import math
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_NAME = r"^[a-z][a-z0-9_]{0,63}$"
_FORBIDDEN = {
"id",
"task_id", "revision_id", "candidate_id", "action_id", "requirement_id",
"claim_id", "evidence_id", "feature_id", "sketch_id", "body_id",
"stable_id", "snapshot_id", "owner_feature_id", "working_head",
"selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken",
"host_face", "mirror_plane",
}
def validation_error_code(error: Exception) -> str:
"""Map strict model validation failures to a stable public diagnostic."""
return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID"
class AuthorModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
@model_validator(mode="before")
@classmethod
def reject_internal_fields(cls, value: Any) -> Any:
if isinstance(value, dict):
found = sorted(
key for key in value
if isinstance(key, str)
and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN)
)
if found:
raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}")
for nested in value.values():
cls.reject_internal_fields(nested)
return value
if isinstance(value, list):
for item in value:
cls.reject_internal_fields(item)
return value
class AuthorWorkplane(AuthorModel):
"""A fully explicit local sketch frame in world millimetres."""
origin_mm: list[float] = Field(min_length=3, max_length=3)
x_dir: list[float] = Field(min_length=3, max_length=3)
normal: list[float] = Field(min_length=3, max_length=3)
class AuthorCircleProfile(AuthorModel):
"""A declarative circle expressed with the user-facing diameter."""
type: Literal["circle"]
diameter_mm: float = Field(gt=0)
center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2)
class AuthorPolygonProfile(AuthorModel):
"""A closed polygon in the local sketch workplane."""
type: Literal["polygon"]
vertices: list[list[float]] = Field(min_length=3)
@field_validator("vertices")
@classmethod
def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]:
if any(len(point) != 2 for point in value):
raise ValueError("polygon vertices must have exactly two coordinates")
return value
class AuthorSketch(AuthorModel):
"""The only authoring sketch form currently accepted by the compiler."""
workplane: AuthorWorkplane
profile: AuthorCircleProfile | AuthorPolygonProfile
class SelectorIntent(AuthorModel):
"""A local feature-output reference, never a Runtime selector token."""
kind: Literal["face", "edge", "axis", "plane", "vertex", "body"]
source: str = Field(
min_length=3,
max_length=160,
description="A local feature output in the form <feature_name>.<output_role>.",
)
match: Literal["unique", "all"] = "unique"
@field_validator("source")
@classmethod
def require_feature_output_reference(cls, value: str) -> str:
feature, separator, role = value.partition(".")
if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role):
raise ValueError("selector source must be <feature_name>.<output_role>")
return value
class AuthorFeature(AuthorModel):
name: str = Field(pattern=_NAME)
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
params: dict[str, Any] = Field(
default_factory=dict,
description="Only parameters from this feature operation's supplied params_schema.",
)
depends_on: list[str] = Field(default_factory=list, max_length=32)
selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32)
sketch: AuthorSketch | None = Field(
default=None,
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
)
class AuthorBody(AuthorModel):
name: str = Field(pattern=_NAME)
features: list[AuthorFeature] = Field(min_length=1, max_length=256)
class AuthoringDocument(AuthorModel):
schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$")
units: str = Field(default="mm", pattern=r"^mm$")
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
assumptions: list[str] = Field(default_factory=list, max_length=64)
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
@model_validator(mode="after")
def validate_symbols(self) -> "AuthoringDocument":
validate_finite(self.model_dump(mode="python"))
bodies = [b.name for b in self.bodies]
if len(bodies) != len(set(bodies)):
raise ValueError("duplicate body name")
names: set[str] = set()
for body in self.bodies:
for feature in body.features:
if feature.name in names:
raise ValueError(f"duplicate feature name: {feature.name}")
names.add(feature.name)
for body in self.bodies:
for feature in body.features:
if len(feature.depends_on) != len(set(feature.depends_on)):
raise ValueError(f"duplicate dependency: {feature.name}")
if any(dep not in names for dep in feature.depends_on):
missing = next(dep for dep in feature.depends_on if dep not in names)
raise ValueError(f"unknown feature reference: {missing}")
return self
def validate_finite(value: Any, path: str = "$") -> None:
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"non-finite number at {path}")
if isinstance(value, dict):
for key, item in value.items():
validate_finite(item, f"{path}.{key}")
elif isinstance(value, list):
for index, item in enumerate(value):
validate_finite(item, f"{path}[{index}]")
@@ -0,0 +1,16 @@
"""Prompt material for the model-facing Authoring CDSL contract."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md"
@lru_cache(maxsize=1)
def load_authoring_guidance() -> str:
try:
return _GUIDANCE.read_text(encoding="utf-8")[:12_000]
except OSError:
return "Use cad.author.v1 only. Never create runtime IDs or selector tokens."
@@ -6,62 +6,25 @@ from hashlib import sha256
import json
from typing import Any, Literal
from app.cad_agent.application.llm_contracts import (
EmptyCommand,
ImageObservation,
MarkdownDocument,
StatelessCandidateReview,
StatelessGeometryConclusion,
StatelessRollbackCheckpoint,
StatelessTopologyRequest,
compiled_requirements_schema,
stateless_final_review_schema,
stateless_next_action_schema,
)
from app.cad_agent.domain.feature_plan import FeaturePlan
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.verifier_registry import default_registry
from app.cad_agent.application.authoring_contract import AuthoringDocument
from app.cad_agent.application.workflow import RequirementsAnalysis
from app.cad_agent.ports import CadRuntime, ModelGateway
CapabilityRole = Literal["author", "reviewer"]
CapabilityRole = Literal["author"]
def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]:
if role == "reviewer":
return [
_tool("observe_images", ImageObservation.model_json_schema()),
# Compatibility conformance probe; regular DAG execution never
# calls a per-node reviewer.
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
_tool("review_final", stateless_final_review_schema(1)),
]
atomic_ids = list(runtime.supported_atomic_ids())
if not atomic_ids:
if not runtime.supported_atomic_ids():
raise RuntimeError("Runtime has no operations for conformance")
tools = [
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
_tool("write_feature_plan", FeaturePlan.model_json_schema()),
# Compatibility probe only; production v3.2 workflow never exposes it.
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
_tool("complete_task", EmptyCommand.model_json_schema()),
return [
_tool("analyze_requirements", RequirementsAnalysis.model_json_schema()),
_tool("write_authoring_cdsl", AuthoringDocument.model_json_schema()),
]
for atomic_id in atomic_ids:
contract = runtime.operation_contract(atomic_id)
tools.append(_tool(
f"conformance_{atomic_id}",
fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]),
))
return tools
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
payload = {"protocol": "cad.single-stage.v1", "role": role, "tools": tools}
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -1,412 +0,0 @@
"""Canonical schemas for every v3 LLM state-changing command.
Providers may parse structured output first, but this module validates the raw
tool arguments a second time before a command reaches a handler.
"""
from __future__ import annotations
from copy import deepcopy
import hashlib
import json
import math
from typing import Annotated, Any, Literal, TypeVar
from jsonschema import Draft202012Validator
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
class StrictDto(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
class AcceptanceClaimInput(StrictDto):
claim_kind: Identifier
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
class SpecRequirementInput(StrictDto):
statement: Annotated[str, Field(min_length=1, max_length=1000)]
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
class MarkdownDocument(StrictDto):
"""A frozen human-readable design artifact, never an executable payload."""
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
class CompiledRequirementInput(StrictDto):
"""One verifier bundle for one server-parsed checklist item.
The checklist text, ordering, source bindings, and all identifiers are
intentionally absent: the service owns them after Markdown is frozen.
"""
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
class CompiledRequirementsSpec(StrictDto):
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
# Kept only so an interrupted process with an already imported old tool schema
# fails at the workflow boundary instead of failing module import. New v3.1
# tasks never expose or accept this aggregate specification.
class RequirementsSpec(StrictDto):
outcome: Literal["ready"]
summary: Annotated[str, Field(min_length=1, max_length=2000)]
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
class RequirementsClarification(StrictDto):
outcome: Literal["clarification"]
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
question: Annotated[str, Field(min_length=1, max_length=500)]
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
pass
class EmptyCommand(StrictDto):
pass
class NextAction(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
intent: ShortText
# The server binds this list from every frozen checklist target. It is not
# author input, so a five-item UI-era limit must not reject a valid task.
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
atomic_id: Identifier
expected_change: ShortText
@model_validator(mode="after")
def _requirement_ids_are_unique(self) -> "NextAction":
if len(self.requirement_ids) != len(set(self.requirement_ids)):
raise ValueError("requirement_ids must not contain duplicates")
return self
class StatelessNextAction(StrictDto):
intent: ShortText
operation: Identifier
expected_change: ShortText
class TopologyRequest(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
limit: int = Field(default=16, ge=1, le=64)
class GeometryConclusion(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
decision: Literal["return_to_action_selection", "rollback"]
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
@model_validator(mode="after")
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
if len(self.evidence_refs) != len(set(self.evidence_refs)):
raise ValueError("evidence_refs must not contain duplicates")
return self
class RollbackCheckpoint(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
checkpoint_token: Identifier
reason: Annotated[str, Field(min_length=1, max_length=360)]
class StatelessTopologyRequest(StrictDto):
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
limit: int = Field(default=16, ge=1, le=64)
class StatelessGeometryConclusion(StrictDto):
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
decision: Literal["return_to_action_selection", "rollback"]
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
class StatelessRollbackCheckpoint(StrictDto):
checkpoint_token: Identifier
reason: Annotated[str, Field(min_length=1, max_length=360)]
class ClaimCoverage(StrictDto):
claim_id: Identifier
status: Literal["pass", "pending", "fail", "not_applicable"]
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
@model_validator(mode="after")
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
if len(self.evidence_refs) != len(set(self.evidence_refs)):
raise ValueError("evidence_refs must not contain duplicates")
return self
class CandidateReview(StrictDto):
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class StatelessCandidateReview(StrictDto):
verdict: Literal["accept", "reject"]
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class VisualClaimDecision(StrictDto):
status: Literal["pass", "fail"]
evidence: Annotated[str, Field(min_length=1, max_length=360)]
class StatelessFinalReview(StrictDto):
verdict: Literal["pass", "repair"]
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class ImageMeasurement(StrictDto):
name: Annotated[str, Field(min_length=1, max_length=160)]
value: float | None = None
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
evidence: Annotated[str, Field(min_length=1, max_length=360)]
confidence: float = Field(ge=0, le=1)
class ImageObservation(StrictDto):
summary: Annotated[str, Field(min_length=1, max_length=2000)]
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
class FinalReview(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
schema = RequirementsAuthorOutput.model_json_schema()
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
if isinstance(requirement, dict):
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
return schema
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
schema = CompiledRequirementsSpec.model_json_schema()
definitions = schema.get("$defs", {})
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
if isinstance(requirement, dict):
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
requirements = schema.get("properties", {}).get("requirements")
if isinstance(requirements, dict):
requirements["minItems"] = target_count
requirements["maxItems"] = target_count
return schema
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
"""Drop harmless compiler chatter before strict requirements validation.
``compile_requirements_spec`` is a compiler stage: the service only needs
the ordered verifier bundles for the frozen checklist items. Real models
sometimes add explanatory fields such as a top-level ``assumptions`` or
per-item ``statement`` even when the dynamic tool schema forbids them. Those
fields are not executable and are not part of the frozen contract, so they
should not abort a task before modeling starts.
The verifier ``expected`` payload is intentionally not sanitized here. It
remains governed by the registry's strict per-claim schema because those
values drive deterministic validation.
"""
value = canonical_json_object(raw_arguments_json)
if isinstance(value, WorkflowError):
return value
requirements = value.get("requirements")
sanitized: dict[str, Any] = {}
if isinstance(requirements, list):
sanitized_requirements: list[Any] = []
for requirement in requirements:
if not isinstance(requirement, dict):
sanitized_requirements.append(requirement)
continue
item: dict[str, Any] = {}
if "assumptions" in requirement:
item["assumptions"] = requirement["assumptions"]
if "acceptance_claims" in requirement:
claims = requirement["acceptance_claims"]
if isinstance(claims, list):
item["acceptance_claims"] = [
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
if isinstance(claim, dict) else claim
for claim in claims
]
else:
item["acceptance_claims"] = claims
sanitized_requirements.append(item)
sanitized["requirements"] = sanitized_requirements
else:
sanitized["requirements"] = requirements
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
schema = StatelessNextAction.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["operation"] = {"enum": atomic_ids}
return schema
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
schema = StatelessFinalReview.model_json_schema()
properties = schema.get("properties", {})
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
if isinstance(visual, dict):
visual["minItems"] = visual_claim_count
visual["maxItems"] = visual_claim_count
return schema
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
schema = StatelessRollbackCheckpoint.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
return schema
def topology_request_schema(working_head: str) -> dict[str, Any]:
schema = TopologyRequest.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
return schema
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
"""Bind a rollback request to immutable checkpoints in the active lineage."""
schema = RollbackCheckpoint.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
return schema
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
definitions = schema.get("$defs", {})
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
if not isinstance(coverage, dict):
return
properties = coverage.get("properties", {})
if isinstance(properties, dict):
properties["claim_id"] = {"enum": claim_ids}
Dto = TypeVar("Dto", bound=StrictDto)
def raw_arguments_hash(raw_arguments_json: str) -> str:
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
def json_depth(value: Any, current: int = 0) -> int:
if isinstance(value, dict):
return max([current, *(json_depth(item, current + 1) for item in value.values())])
if isinstance(value, list):
return max([current, *(json_depth(item, current + 1) for item in value)])
return current
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
"""Bound and parse raw arguments before any schema-specific validation."""
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
try:
value = json.loads(raw_arguments_json)
except json.JSONDecodeError as error:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
if not isinstance(value, dict):
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
if json_depth(value) > max_depth:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
if _contains_non_finite_number(value):
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
return value
def _contains_non_finite_number(value: Any) -> bool:
if isinstance(value, float):
return not math.isfinite(value)
if isinstance(value, dict):
return any(_contains_non_finite_number(item) for item in value.values())
if isinstance(value, list):
return any(_contains_non_finite_number(item) for item in value)
return False
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
"""Revalidate raw arguments against the current dynamic JSON Schema."""
value = canonical_json_object(raw_arguments_json)
if isinstance(value, WorkflowError):
return value
errors = [
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
]
if errors:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
return None
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
"""Parse raw tool arguments once and return field-level DTO failures safely."""
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
if isinstance(value, WorkflowError):
return value
try:
return model.model_validate(value)
except ValidationError as error:
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
"""Require exactly one known tool call; no provider parser is trusted."""
if len(tool_calls) != 1:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
name = str(function.get("name") or "") if isinstance(function, dict) else ""
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
if name != allowed_name:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
return name, raw
@@ -1,670 +0,0 @@
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
import re
from typing import Any, Callable
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
from app.cad_agent.application.results import Accepted, Rejected
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
from app.cad_agent.domain.verifier_registry import VerifierRegistry
from app.cad_agent.ports import ArtifactStore, TaskRepository
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
_BORE_MARKERS = ("bore", "hole", "孔")
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
class RequirementsCommandHandler:
"""Persist frozen documents and compile their checklist into a contract.
The model never names targets or internal objects during compilation. The
service derives those values strictly from the immutable checklist.
"""
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
self.repository = repository
self.artifacts = artifacts
self.registry = registry
self.atomic_ids = atomic_ids or (lambda: ())
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
self._evaluation_capability_gaps[task_id] = [
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
for item in validation_capability_gaps or () if isinstance(item, dict)
]
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
claims = self._evaluation_contract_oracles.get(task_id)
return None if claims is None else {
"evaluation_only": True,
"required_claims": deepcopy(claims),
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
}
@staticmethod
def document_schema() -> dict[str, Any]:
return MarkdownDocument.model_json_schema()
def compiler_schema(self, task_id: str) -> dict[str, Any]:
return compiled_requirements_schema(
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
len(self._checklist_items(task_id)),
)
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
"""Return the plan tool schema bound to the persisted planning state.
A model is allowed to choose node content, but it must not guess the
immutable lineage identifiers of a plan revision. Binding those values
as enums prevents a requirements-contract hash (or a stale plan hash)
from being mistaken for ``parent_plan_hash``.
"""
schema = FeaturePlan.model_json_schema()
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
node_properties = node.get("properties") if isinstance(node, dict) else None
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
if isinstance(atomic, dict):
atomic["enum"] = list(self.atomic_ids())
state = self.repository.get_state(task_id)
previous: FeaturePlan | None = None
if state is not None and state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
previous = None
parent_hash = plan_hash(previous) if previous is not None else ""
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
if isinstance(parent, dict):
parent["enum"] = [parent_hash]
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
if isinstance(replaced, dict):
replaced.update({
"type": "array",
"uniqueItems": True,
"minItems": len(replacements),
"maxItems": len(replacements),
"items": {"enum": replacements},
})
contract = self.artifacts.read_requirements_contract(
task_id,
state.requirements_contract_path if state is not None else "",
) or {}
deterministic_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") == "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
visual_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
if isinstance(node_claim_ids, dict):
# Authors occasionally repeat a visual claim on the node that
# creates the feature as well as in final_claim_ids. Accept that
# harmless reference at the tool boundary; submit_feature_plan()
# removes it before the immutable DAG is validated and written.
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
if isinstance(final_claim_ids, dict):
final_claim_ids["items"] = {"enum": visual_claim_ids}
final_claim_ids["maxItems"] = len(visual_claim_ids)
return schema
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
# Compatibility shim for callers compiled against v3.1. The v3.2
# coordinator never offers this method to an LLM.
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
"""Freeze a validated initial plan or full subgraph plan revision."""
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
if not isinstance(contract, dict):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
plan = self._normalize_feature_plan_visual_references(plan, contract)
plan = self._assign_unowned_global_health_claims(plan, contract)
previous: FeaturePlan | None = None
completed: dict[str, str] = {}
if state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
digest = plan_hash(plan)
path = f"plans/feature-plan-{digest}.json"
event = "feature_plan_written" if previous is None else "feature_plan_revised"
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
events: list[dict[str, Any]] = [{
"event": event,
"invocation_id": invocation_id,
"plan_path": written,
"plan_hash": digest,
"parent_plan_hash": plan.parent_plan_hash,
"replaces_node_ids": plan.replaces_node_ids,
}]
if previous is not None:
old_nodes = {node.node_id: node for node in previous.nodes}
old_hash = plan_hash(previous)
events.extend({
"event": "feature_node_invalidated",
"node_id": node_id,
"node_hash": node_hash(old_nodes[node_id]),
"plan_hash": old_hash,
"replacement_plan_hash": digest,
} for node_id in plan.replaces_node_ids)
if not self._commit(next_state, events, invocation, result):
return Rejected(self._stale())
return result
@staticmethod
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Drop non-owning visual references from feature nodes.
A node's ``claim_ids`` drive synchronous deterministic acceptance.
Visual claims are owned solely by ``final_claim_ids`` and have no
node-local verifier. Retaining a repeated visual ID therefore adds
no behavior and turns an otherwise valid plan into a schema retry.
The contract validation below still requires every visual claim to be
present exactly once in ``final_claim_ids``.
"""
visual_claim_ids = {
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
}
if not visual_claim_ids or not any(
claim_id in visual_claim_ids
for node in plan.nodes
for claim_id in node.claim_ids
):
return plan
normalized = plan.model_copy(deep=True)
for node in normalized.nodes:
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
return normalized
@staticmethod
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Bind global solid-health claims to the unique root body feature.
``single_connected_body`` and ``solid_count_equals`` are checked as
global health on every feature checkpoint. When a plan has exactly
one root additive feature, their node owner is consequently
determined without choosing any geometry strategy. This prevents an
otherwise complete plan from failing merely because an author omitted
the redundant ownership annotation.
"""
claims = {
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
}
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
unowned = [
claim_id for claim_id, claim_kind in claims.items()
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
]
roots = [
node for node in plan.nodes
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
]
if not unowned or len(roots) != 1:
return plan
normalized = plan.model_copy(deep=True)
root_id = roots[0].node_id
for node in normalized.nodes:
if node.node_id == root_id:
node.claim_ids = [*node.claim_ids, *unowned]
break
return normalized
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
statuses = scheduler.statuses()
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
if not failed:
return set()
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
for node in plan.nodes:
for dependency in node.depends_on:
children.setdefault(dependency, set()).add(node.node_id)
result = set(failed)
pending = list(failed)
while pending:
current = pending.pop()
for child in children.get(current, set()):
if statuses.get(child) != "done" and child not in result:
result.add(child)
pending.append(child)
return result
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
targets = self._checklist_items(task_id)
if len(output.requirements) != len(targets):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
field_errors = [
*self._claim_errors(normalized_output),
*self._relationship_claim_errors(normalized_output, targets),
]
if field_errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
source_ids = list(self.artifacts.read_source_index(task_id))
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
warnings = [
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
*compiler_warnings,
]
requirements: list[dict[str, Any]] = []
claim_position = 1
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
claims: list[dict[str, Any]] = []
for claim in compiled.acceptance_claims:
definition = self.registry.definition(claim.claim_kind)
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
claim_position += 1
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
try:
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
return Rejected(self._stale())
return result
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
visual = iter(review.get("visual_claims") or ())
rows = ["# Completion Result", "", "## Checklist", ""]
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
statuses: list[str] = []
evidence: list[str] = []
for claim in requirement.get("acceptance_claims") or ():
if not isinstance(claim, dict):
continue
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
statuses.append(str(result.get("status") or "unknown"))
value = result.get("evidence")
if value:
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
rows.extend(f" - Evidence: {value}" for value in evidence)
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != phase:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
errors = validator(document.markdown)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
except OSError as error:
return self._park_for_storage_retry(state, str(error))
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
next_state = transition(state, event, **kwargs)
result = Accepted({"phase": next_state.phase.value, "path": written})
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
return Rejected(self._stale())
return result
def _checklist_items(self, task_id: str) -> list[str]:
path = self.artifacts.task_dir(task_id) / "completion-target.md"
text = path.read_text(encoding="utf-8") if path.is_file() else ""
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
for requirement_index, requirement in enumerate(output.requirements):
for claim_index, claim in enumerate(requirement.acceptance_claims):
try:
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
except ValueError:
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
return errors
@staticmethod
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
"""Require explicit coverage for an unambiguous centered-bore target.
The compiler remains free to choose claims for open-ended CAD prose.
A checklist item that explicitly says a bore is centered/concentric is
different: dropping that relationship leaves a measurable user fact
with no acceptance owner. The service only checks its presence here;
the registry validates its numeric parameters independently.
"""
errors: list[dict[str, str]] = []
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(
claim.claim_kind == "concentric_bore_to_outer_cylinder"
for claim in requirement.acceptance_claims
):
continue
errors.append({
"path": f"/requirements/{index}/acceptance_claims",
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
})
return errors
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
normalized = output.model_copy(deep=True)
warnings: list[str] = []
for target, requirement in zip(targets, normalized.requirements, strict=True):
normalized_claims = []
for claim in requirement.acceptance_claims:
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
warnings.append(
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_unbacked_coaxial_bore_group(normalized, claim):
warnings.append(
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
warnings.append(
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
warnings.append(
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
)
continue
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
normalized_claims.append(claim)
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
"claim_kind": "visual",
"expected": {"description": target[:360]},
})]
self._derive_centered_bore_claims(normalized, targets)
return normalized, list(dict.fromkeys(warnings))
@staticmethod
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
"""Keep a circular-hole verifier from accepting or rejecting a slot.
``rectangular_corner_through_bore_pattern`` measures four complete
cylindrical bores at equal edge offsets. An obround slot has two arc
ends and straight flanks; treating its stated length as an edge offset
produces an unsatisfiable contract even when the CAD is correct.
"""
return (
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
)
@staticmethod
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
"""Attach a measurable concentricity claim when its inputs are frozen.
The requirements compiler receives a Markdown checklist, not runtime
geometry IDs. Once it has already compiled an external cylindrical
diameter and an explicitly centred bore diameter, their relationship
is a service-owned mechanical consequence. Requiring an author to
remember the internal verifier name makes a complete user request
fail for a bookkeeping omission rather than a CAD decision.
"""
outer_diameters: list[float] = []
for requirement in output.requirements:
for claim in requirement.acceptance_claims:
expected = claim.expected
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
outer_diameters.append(float(diameter))
if not outer_diameters:
return
outer_diameter = max(outer_diameters)
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
for target, requirement in zip(targets, output.requirements, strict=True):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
continue
bore_diameter = next((
float(claim.expected["diameter_mm"])
for claim in requirement.acceptance_claims
if claim.claim_kind in bore_claim_kinds
and isinstance(claim.expected, dict)
and isinstance(claim.expected.get("diameter_mm"), (int, float))
and float(claim.expected["diameter_mm"]) > 0
), None)
if bore_diameter is None:
continue
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
"claim_kind": "concentric_bore_to_outer_cylinder",
"expected": {
"bore_diameter_mm": bore_diameter,
"outer_diameter_mm": outer_diameter,
"tolerance_mm": 0.01,
},
}))
@staticmethod
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
"""Reject a bore-group verifier when the contract has no such group.
``coaxial_through_bore_group`` measures multiple inner bores of one
diameter. It cannot prove a lone central bore is concentric with an
external cylindrical wall. This is a mechanical consistency check:
some through-bore claim must request at least the group count.
"""
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
return False
expected = getattr(claim, "expected", {})
if not isinstance(expected, dict):
return True
diameter = expected.get("diameter_mm")
count = expected.get("count")
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
return True
for requirement in output.requirements:
for candidate in requirement.acceptance_claims:
candidate_expected = getattr(candidate, "expected", {})
if (
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
and isinstance(candidate_expected, dict)
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
and isinstance(candidate_expected.get("count"), int)
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
and int(candidate_expected["count"]) >= count
):
return False
return True
@staticmethod
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
return False
if RequirementsCommandHandler._target_describes_finished_envelope(target):
return False
return any(
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
for item in claims
)
@staticmethod
def _target_describes_finished_envelope(target: str) -> bool:
lowered = target.lower()
return any(token in lowered for token in (
"overall",
"total",
"finished part",
"entire part",
"whole part",
"bounding box",
"envelope",
"总",
"整体",
"成品",
"全高",
"包围盒",
))
@staticmethod
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
value = bbox_claim.expected.get("value")
if not isinstance(value, (int, float)):
return
for item in claims:
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
continue
expected = getattr(item, "expected", None)
if not isinstance(expected, dict) or "axial_span_mm" in expected:
continue
expected["axial_span_mm"] = value
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
return
@staticmethod
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
# Markdown is a human-facing semantic artifact. Its content is frozen
# verbatim and is not executable, so headings are guidance for the
# author rather than a server-enforced protocol.
return []
@staticmethod
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
errors: list[dict[str, str]] = []
if not values:
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
if len(values) != len(set(values)):
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
return errors
@staticmethod
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
return []
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
invocation = self.repository.get_invocation(task_id, invocation_id)
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
@staticmethod
def _restore(payload: dict[str, Any]) -> Accepted:
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
@staticmethod
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
@staticmethod
def _stale() -> WorkflowError:
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
@@ -1,29 +0,0 @@
"""Typed results returned by all command handlers."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from app.cad_agent.domain.errors import WorkflowError
@dataclass(frozen=True, slots=True)
class Accepted:
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class Rejected:
error: WorkflowError
@dataclass(frozen=True, slots=True)
class Waiting:
error: WorkflowError
@dataclass(frozen=True, slots=True)
class FailedInternal:
correlation_id: str
message: str
@@ -0,0 +1,99 @@
"""Deterministic execution service for the single-stage Authoring protocol.
The service is deliberately synchronous and side-effect bounded so the HTTP
workflow can call it after one author response (and at most two repairs).
"""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any
from app.cad_agent.ports import AdapterUnavailable
class SingleStageExecutor:
MAX_REPAIRS = 2
def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None:
self.repository, self.artifacts, self.runtime = repository, artifacts, runtime
def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
"""Compile once and persist immutable compiler inputs and output."""
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
raise ValueError("repair budget exhausted")
runtime_cdsl, audit = self.runtime.compile_authoring(authoring)
digest = self._digest(authoring)
attempt = repair_count + 1
runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json"
audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json"
self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl)
self.artifacts.write_json_once(task_id, audit_path, audit)
return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest}
def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]:
"""Build one compiled CDSL document and publish any executable prefix."""
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
raise ValueError("repair budget exhausted")
digest = digest or self._digest(authoring)
stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", {
"schema_version": "cad.single-stage.v1", "authoring": authoring,
"runtime": runtime_cdsl, "compile_audit": audit,
})
try:
rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id)
except (OSError, AdapterUnavailable):
raise
except Exception as error:
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
"schema_version": "cad.build-diagnostics.v1",
"diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}],
})
return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit}
diagnostics = self._decorate_diagnostics(failures, audit)
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
"schema_version": "cad.build-diagnostics.v1",
"diagnostics": diagnostics,
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
})
if rebuilt:
revision = f"rev_{digest}"
self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", {
"schema_version": "cad.single-stage.staging-manifest.v1",
"stage_id": stage.stage_id,
"revision_id": revision,
"repair_count": repair_count,
"source_sha256": audit.get("source_sha256", ""),
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
})
paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision)
else:
paths = {}
return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit}
def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
"""Compatibility convenience for direct callers and focused tests."""
compiled = self.compile(task_id, authoring, repair_count=repair_count)
return {
**self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]),
"runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"],
}
@staticmethod
def _digest(authoring: dict[str, Any]) -> str:
encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
return sha256(encoded).hexdigest()[:16]
@staticmethod
def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]:
names = {
str(feature_id): str(name)
for name, feature_id in (audit.get("feature_ids") or {}).items()
if isinstance(name, str) and isinstance(feature_id, str)
}
result: list[dict[str, Any]] = []
for failure in failures:
if not isinstance(failure, dict):
continue
feature_id = str(failure.get("feature_id") or "")
result.append({**failure, "feature_name": names.get(feature_id, "")})
return result
File diff suppressed because it is too large Load Diff
+28 -40
View File
@@ -1,4 +1,4 @@
"""Protocol v3 composition root. This is the only layer joining adapters."""
"""Single-stage Authoring CDSL composition root."""
from __future__ import annotations
@@ -6,70 +6,58 @@ from dataclasses import dataclass
import shutil
from app.cad_agent.adapters.artifact_store import FileArtifactStore
from app.cad_agent.adapters.author_guidance import FileAuthorGuidance
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
from app.cad_agent.adapters.runtime import ProfileCadRuntime
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
from app.cad_agent.application.action_handlers import ActionCommandHandler
from app.cad_agent.application.outbox import OutboxDispatcher
from app.cad_agent.application.requirements import RequirementsCommandHandler
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
from app.cad_agent.domain.verifier_registry import default_registry
from app.settings import BACKEND_ROOT, Settings
from app.cad_agent.application.workflow import WorkflowConfig, WorkflowCoordinator
from app.cad_agent.application.single_stage import SingleStageExecutor
from app.settings import Settings
from app.services.storage import WorkspaceStore
@dataclass(frozen=True, slots=True)
class V3Services:
class CadServices:
repository: SqliteTaskRepository
artifacts: FileArtifactStore
workflow: WorkflowCoordinator
models: StructuredModelGateway
outbox: OutboxDispatcher
single_stage: SingleStageExecutor
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.
def compose_cad_services(settings: Settings) -> CadServices:
database_root = settings.task_root.parent
legacy_database = database_root / "autonomous-cad-v3.sqlite3"
removed_legacy_database = False
if legacy_database.exists():
# The removed coordinator persisted incompatible task/action state in
# its own database. Delete it as part of the deliberate destructive
# migration, including SQLite sidecars if a worker stopped mid-write.
for candidate in (legacy_database, *(database_root / f"{legacy_database.name}{suffix}" for suffix in ("-wal", "-shm"))):
if candidate.exists():
candidate.unlink()
removed_legacy_database = True
repository = SqliteTaskRepository(database_root / "autonomous-cad-single-stage.sqlite3")
protocol_reset = repository.protocol_reset or removed_legacy_database
if protocol_reset and settings.task_root.exists():
# Old task artifacts have no valid interpretation under the Authoring
# protocol, so clear them together with the task database.
shutil.rmtree(settings.task_root)
if repository.protocol_reset:
if protocol_reset:
WorkspaceStore(settings).clear_current_task_references()
artifacts = FileArtifactStore(settings.task_root)
runtime = ProfileCadRuntime(settings)
registry = default_registry()
verifier = RegistryVerifierExecutor(registry)
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
fallbacks = tuple(
ModelIdentity(provider.id, model.id)
for provider in settings.providers
if provider.configured
for model in provider.models[:1]
)
models = StructuredModelGateway(settings)
outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher())
single_stage = SingleStageExecutor(repository, artifacts, runtime)
workflow = WorkflowCoordinator(
WorkflowConfig(
max_turns=max(8, settings.agent_tool_calls_per_cycle * 8),
format_error_limit=settings.agent_format_error_repeat_limit,
author_fallbacks=fallbacks,
),
WorkflowConfig(),
repository,
artifacts,
runtime,
models,
RenderedReviewGateway(models),
requirements,
actions,
FileAuthorGuidance(
BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance",
enabled=settings.agent_author_guidance_enabled,
max_chars=settings.agent_author_guidance_max_chars,
),
single_stage,
)
return V3Services(repository, artifacts, workflow, models, outbox)
return CadServices(repository, artifacts, workflow, models, outbox, single_stage)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Pure domain objects and policies for protocol v3."""
"""Pure domain objects and policies for the Authoring CDSL protocol."""
from .errors import ErrorCode, WorkflowError
from .state import TaskPhase, TaskState, transition
@@ -1,20 +0,0 @@
"""Canonical partial matching for acceptance-claim expectations."""
from __future__ import annotations
from typing import Any
def contains_expected(actual: Any, required: Any) -> bool:
"""Allow an actual claim to add provider-chosen optional fields only."""
if isinstance(required, dict):
return isinstance(actual, dict) and all(
key in actual and contains_expected(actual[key], value)
for key, value in required.items()
)
if isinstance(required, list):
return isinstance(actual, list) and len(actual) == len(required) and all(
contains_expected(actual_item, required_item)
for actual_item, required_item in zip(actual, required, strict=True)
)
return actual == required
+11 -15
View File
@@ -1,4 +1,4 @@
"""Typed, serializable failures used across the v3 workflow."""
"""Typed, serializable failures used across the single-stage workflow."""
from __future__ import annotations
@@ -9,29 +9,25 @@ from typing import Any
class ErrorCode(StrEnum):
CANCELLED = "CANCELLED"
AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID"
AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED"
STALE_WORKING_HEAD = "STALE_WORKING_HEAD"
FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT"
RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED"
RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID"
CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED"
CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED"
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING"
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
STORAGE_FAILURE = "STORAGE_FAILURE"
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
REQUIREMENTS_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"
AUTHOR_FORBIDDEN_FIELD = "AUTHOR_FORBIDDEN_FIELD"
AUTHOR_SCHEMA_INVALID = "AUTHOR_SCHEMA_INVALID"
AUTHOR_REFERENCE_INVALID = "AUTHOR_REFERENCE_INVALID"
AUTHOR_CYCLE = "AUTHOR_CYCLE"
OPERATION_UNSUPPORTED = "OPERATION_UNSUPPORTED"
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
SELECTOR_AMBIGUOUS = "SELECTOR_AMBIGUOUS"
SELECTOR_DEPENDENCY_UNAVAILABLE = "SELECTOR_DEPENDENCY_UNAVAILABLE"
SELECTOR_KIND_MISMATCH = "SELECTOR_KIND_MISMATCH"
ENGINE_EXECUTION_FAILED = "ENGINE_EXECUTION_FAILED"
@dataclass(frozen=True, slots=True)
@@ -1,265 +0,0 @@
"""Immutable feature-DAG planning contracts and deterministic scheduling."""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
class FeatureNode(_StrictModel):
node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
priority: int = Field(ge=0, le=100_000)
intent: str = Field(min_length=1, max_length=360)
atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
depends_on: list[str] = Field(default_factory=list, max_length=64)
claim_ids: list[str] = Field(default_factory=list, max_length=128)
expected_change: str = Field(min_length=1, max_length=360)
@model_validator(mode="after")
def _unique_references(self) -> "FeatureNode":
if len(self.depends_on) != len(set(self.depends_on)):
raise ValueError("depends_on must not contain duplicates")
if len(self.claim_ids) != len(set(self.claim_ids)):
raise ValueError("claim_ids must not contain duplicates")
if self.node_id in self.depends_on:
raise ValueError("a feature node cannot depend on itself")
return self
class FeaturePlan(_StrictModel):
schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$")
parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$")
replaces_node_ids: list[str] = Field(default_factory=list, max_length=128)
nodes: list[FeatureNode] = Field(min_length=1, max_length=256)
final_claim_ids: list[str] = Field(default_factory=list, max_length=128)
@model_validator(mode="after")
def _unique_plan_fields(self) -> "FeaturePlan":
node_ids = [node.node_id for node in self.nodes]
priorities = [node.priority for node in self.nodes]
if len(node_ids) != len(set(node_ids)):
raise ValueError("node_id values must be unique")
if len(priorities) != len(set(priorities)):
raise ValueError("priority values must be unique")
if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)):
raise ValueError("replaces_node_ids must not contain duplicates")
if len(self.final_claim_ids) != len(set(self.final_claim_ids)):
raise ValueError("final_claim_ids must not contain duplicates")
return self
def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str:
payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def node_hash(node: FeatureNode | dict[str, Any]) -> str:
payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
for claim in requirement.get("acceptance_claims") or ():
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str):
result[claim["claim_id"]] = claim
return result
def validate_feature_plan(
plan: FeaturePlan,
contract: dict[str, Any],
atomic_ids: set[str] | frozenset[str] | tuple[str, ...],
*,
previous_plan: FeaturePlan | None = None,
completed_node_hashes: dict[str, str] | None = None,
required_replacements: set[str] | None = None,
) -> list[dict[str, str]]:
"""Return stable, tool-facing validation errors for a frozen plan."""
errors: list[dict[str, str]] = []
nodes = {node.node_id: node for node in plan.nodes}
known_atoms = set(atomic_ids)
claims = contract_claims(contract)
assigned: dict[str, str] = {}
for index, node in enumerate(plan.nodes):
prefix = f"/nodes/{index}"
if node.atomic_id not in known_atoms:
errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"})
for dependency in node.depends_on:
if dependency not in nodes:
errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"})
for claim_id in node.claim_ids:
claim = claims.get(claim_id)
if claim is None:
errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
continue
if claim.get("verification_mode") != "deterministic":
errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"})
owner = assigned.setdefault(claim_id, node.node_id)
if owner != node.node_id:
errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"})
final = set(plan.final_claim_ids)
for claim_id in plan.final_claim_ids:
claim = claims.get(claim_id)
if claim is None:
errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
elif claim.get("verification_mode") == "deterministic":
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"})
for claim_id, claim in claims.items():
deterministic = claim.get("verification_mode") == "deterministic"
if deterministic and claim_id not in assigned:
errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"})
if not deterministic and claim_id not in final:
errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"})
if deterministic and claim_id in final:
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"})
errors.extend(_cycle_errors(nodes))
if previous_plan is None:
if plan.parent_plan_hash:
errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"})
if plan.replaces_node_ids:
errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"})
else:
errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set()))
return errors
def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]:
visiting: set[str] = set()
visited: set[str] = set()
errors: list[dict[str, str]] = []
def walk(current: str) -> None:
if current in visiting:
errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"})
return
if current in visited:
return
visiting.add(current)
for dependency in nodes[current].depends_on:
if dependency in nodes:
walk(dependency)
visiting.remove(current)
visited.add(current)
for node_id in nodes:
walk(node_id)
return errors[:1]
def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
old_nodes = {node.node_id: node for node in previous.nodes}
next_nodes = {node.node_id: node for node in plan.nodes}
if plan.parent_plan_hash != plan_hash(previous):
errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"})
replaced = set(plan.replaces_node_ids)
if required_replacements and replaced != required_replacements:
errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"})
for node_id, frozen_hash in completed.items():
node = next_nodes.get(node_id)
if node is None:
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"})
elif node_hash(node) != frozen_hash:
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"})
if node_id in replaced:
errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"})
for node_id, old_node in old_nodes.items():
if node_id in replaced:
continue
current = next_nodes.get(node_id)
if current is None:
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"})
elif node_hash(current) != node_hash(old_node):
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"})
for node_id in replaced:
if node_id not in old_nodes:
errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"})
if node_id in next_nodes:
errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"})
return errors
class FeatureScheduler:
"""Derive a plan's runnable node from immutable ledger evidence."""
def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None:
self.plan = plan
self.events = events
self._nodes = {node.node_id: node for node in plan.nodes}
def statuses(self) -> dict[str, str]:
states: dict[str, str] = {node_id: "pending" for node_id in self._nodes}
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
for event in self.events:
node_id = str(event.get("node_id") or "")
if node_id not in states or event.get("node_hash") != expected_hashes[node_id]:
continue
if event.get("event") == "feature_node_verified":
states[node_id] = "done"
elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done":
states[node_id] = "invalidated"
elif event.get("event") == "feature_node_failed" and states[node_id] != "done":
states[node_id] = "failed" if bool(event.get("terminal")) else "pending"
elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending":
states[node_id] = "running"
for node in self.plan.nodes:
if states[node.node_id] in {"done", "failed", "invalidated"}:
continue
dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on]
if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states):
states[node.node_id] = "blocked"
elif all(value == "done" for value in dependency_states):
states[node.node_id] = "ready" if states[node.node_id] != "running" else "running"
return states
def next_ready(self) -> FeatureNode | None:
statuses = self.statuses()
ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"]
return min(ready, key=lambda node: node.priority) if ready else None
def all_done(self) -> bool:
return all(value == "done" for value in self.statuses().values())
def feature_ids(self) -> dict[str, str]:
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
result: dict[str, str] = {}
for event in self.events:
node_id = str(event.get("node_id") or "")
feature_id = str(event.get("feature_id") or "")
if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id:
result[node_id] = feature_id
return result
def completed_node_hashes(self) -> dict[str, str]:
statuses = self.statuses()
return {
node_id: node_hash(self._nodes[node_id])
for node_id, status in statuses.items()
if status == "done"
}
def failure_count(self, node_id: str, failure_class: str) -> int:
expected = node_hash(self._nodes[node_id])
return sum(
1
for event in self.events
if event.get("event") == "feature_node_failed"
and event.get("node_id") == node_id
and event.get("node_hash") == expected
and event.get("failure_class") == failure_class
)
@@ -1,8 +1,7 @@
"""Versioned runtime operation contracts and dynamic fragment schemas."""
"""Versioned runtime operation contracts for the Authoring compiler."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any
@@ -15,25 +14,6 @@ class OperationContractError(ValueError):
pass
SEMANTIC_PREFLIGHT_NAMES = frozenset({
"sketch_workplane",
"profile_non_self_intersecting",
"host_face_exists",
"hole_positions_on_host_plane",
"cut_exit_distance",
"requires_active_solid",
"revolve_axis_on_sketch",
"reference_plane_nonzero_normal",
"reference_axis_nonzero_direction",
"selected_edges_exist",
"source_features_exist",
"mirror_plane_exists",
"loft_profiles_exist",
"loft_profiles_closed",
"loft_profiles_single_region",
})
def canonical_hash(value: dict[str, Any]) -> str:
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -58,10 +38,16 @@ def _is_closed(schema: Any) -> bool:
return True
def is_authoring_schema_closed(schema: Any) -> bool:
"""Whether an operation can safely be exposed in the Authoring whitelist."""
return _is_closed(schema)
def validate_operation_contract(contract: dict[str, Any]) -> None:
required = {
"atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy",
"server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers",
"runtime_capability",
}
if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}:
raise OperationContractError("Operation contract has unknown or missing fields")
@@ -111,113 +97,43 @@ def validate_operation_contract(contract: dict[str, Any]) -> None:
if reference["mode"] == "snapshot_bound":
if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
raise OperationContractError("Snapshot-bound reference policy is invalid")
if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True:
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
reference_name = reference["slot"].removeprefix("params.")
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
reference_schema = properties.get(reference_name)
required_params = params.get("required") if isinstance(params.get("required"), list) else []
slot = reference["slot"]
if (
not isinstance(reference_schema, dict)
or reference_schema.get("type") != "array"
or not isinstance(reference_schema.get("items"), dict)
or reference_name not in required_params
not isinstance(slot, str)
or (slot != "feature.selectors" and not slot.startswith("params."))
or reference["token_kind"] not in {"face", "edge", "plane", "axis", "body", "feature"}
or not isinstance(reference["min_items"], int)
or not isinstance(reference["max_items"], int)
or not 0 <= reference["min_items"] <= reference["max_items"] <= 64
or reference["snapshot_bound"] is not True
):
raise OperationContractError("Snapshot-bound reference slot must be a required author array")
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
if slot == "feature.selectors":
if shape["selector_tokens"] != "required" or selector["token_kind"] != reference["token_kind"]:
raise OperationContractError("Feature selector reference policy disagrees with selector policy")
else:
reference_name = slot.removeprefix("params.")
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
reference_schema = properties.get(reference_name)
if not isinstance(reference_schema, dict) or reference_schema.get("type") not in {"array", "string"}:
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
if reference_schema.get("type") == "string" and reference["max_items"] > 1:
raise OperationContractError("Scalar snapshot-bound reference must allow at most one item")
if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]):
raise OperationContractError("Operation semantic preflight is invalid")
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES:
raise OperationContractError("Operation semantic preflight names are unknown or duplicated")
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]):
raise OperationContractError("Operation semantic preflight names are duplicated")
if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]):
raise OperationContractError("Operation candidate_verifiers are invalid")
if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]):
raise OperationContractError("Operation candidate verifiers are duplicated")
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"]
params = deepcopy(contract["author_params_schema"])
reference = contract["reference_policy"]
if reference["mode"] == "snapshot_bound":
slot = str(reference["slot"]).removeprefix("params.")
items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None
if not isinstance(items, dict):
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
items.clear()
items.update({"enum": reference_tokens or []})
feature_properties: dict[str, Any] = {
"atomic_id": {"const": contract["atomic_id"]},
"params": params,
runtime_capability = contract["runtime_capability"]
required_capability_flags = {
"body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok",
}
feature_required = ["atomic_id", "params"]
if shape["selector_tokens"] == "required":
policy = contract["selector_policy"]
feature_properties["selector_tokens"] = {
"type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"],
"maxItems": policy["max_items"], "uniqueItems": True,
"description": (
"Required author input. Copy the opaque selector token returned by the current topology "
"snapshot here; do not omit it and do not put a host face in params. The server resolves this "
"token into the host selector after schema validation."
),
}
feature_required.append("selector_tokens")
feature = {
"type": "object",
"description": (
"One atomic feature. selector_tokens, when present, is an author-supplied topology token array "
"rather than a server-filled params field."
),
"properties": feature_properties,
"required": feature_required,
"additionalProperties": False,
}
properties: dict[str, Any] = {"feature": feature}
required = ["feature"]
if shape["sketch"] == "required":
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, 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))
]
def _point(size: int) -> dict[str, Any]:
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]:
point2 = _point(2)
point3 = _point(3)
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},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False},
{"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False},
]
}
return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False}
if (
not isinstance(runtime_capability, dict)
or set(runtime_capability) != required_capability_flags
or not all(isinstance(value, bool) for value in runtime_capability.values())
):
raise OperationContractError("Operation runtime capability is invalid")
+70 -185
View File
@@ -1,56 +1,24 @@
"""Finite workflow state machine. This module has no persistence imports."""
"""Finite state for the single-stage Authoring CDSL protocol."""
from __future__ import annotations
from dataclasses import dataclass, replace
from enum import StrEnum
from .errors import ErrorCode, WorkflowError
from .errors import ErrorCode
class TaskPhase(StrEnum):
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN"
SCHEDULING_FEATURE = "SCHEDULING_FEATURE"
FEATURE_PENDING = "FEATURE_PENDING"
FEATURE_BUILDING = "FEATURE_BUILDING"
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
# Legacy v3.1 phases remain readable only so an interrupted process can
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
AWAITING_ACTION = "AWAITING_ACTION"
ACTION_PENDING = "ACTION_PENDING"
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
CANDIDATE_REVIEW = "CANDIDATE_REVIEW"
FINAL_VALIDATION = "FINAL_VALIDATION"
WAITING_RETRY = "WAITING_RETRY"
ANALYZING_REQUEST = "ANALYZING_REQUEST"
AUTHORING_CDSL = "AUTHORING_CDSL"
COMPILING_CDSL = "COMPILING_CDSL"
BUILDING = "BUILDING"
REPAIRING = "REPAIRING"
PUBLISHING_BEST_EFFORT = "PUBLISHING_BEST_EFFORT"
WAITING_FOR_USER = "WAITING_FOR_USER"
CANCELLED = "CANCELLED"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
@dataclass(frozen=True, slots=True)
class PendingAction:
action_id: str
working_head: str
intent: str
requirement_ids: tuple[str, ...]
atomic_id: str
expected_change: str
contract_hash: str
idempotency_key: str
node_id: str = ""
plan_hash: str = ""
claim_ids: tuple[str, ...] = ()
depends_on_node_ids: tuple[str, ...] = ()
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
# v3.2 code uses this alias to make the ownership boundary explicit.
PendingFeature = PendingAction
CANCELLED = "CANCELLED"
@dataclass(frozen=True, slots=True)
@@ -59,167 +27,84 @@ class TaskState:
phase: TaskPhase
version: int
active_revision: str = ""
pending_action: PendingAction | None = None
candidate_id: str = ""
candidate_stage_id: str = ""
repair_required: bool = False
repair_count: int = 0
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 = ""
feature_plan_path: str = ""
feature_plan_hash: str = ""
feature_stage_id: str = ""
requirements_path: str = ""
authoring_path: str = ""
runtime_cdsl_path: str = ""
compile_audit_path: str = ""
diagnostics_path: str = ""
completion_path: str = ""
clarification_path: str = ""
requirements_contract_path: str = ""
@property
def pending_feature(self) -> PendingFeature | None:
return self.pending_action
@property
def working_head(self) -> str:
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
# 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_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.COMPILING_FEATURE_PLAN,
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE,
# Retained for direct v3.1 handler callers only. The v3.2 workflow never
# exposes this event or accepts a Markdown plan from a model.
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE,
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING,
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION,
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(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_DOCUMENT,
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
(TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING,
# Runtime semantic preflight happens before candidate staging. It must
# leave the checkpoint untouched and return control to the author for a
# fresh observation/action decision, never masquerade as a format error.
(TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION,
(TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING,
(TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION,
(TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW,
(TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING,
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
(TaskPhase.ANALYZING_REQUEST, "analysis_written"): TaskPhase.AUTHORING_CDSL,
(TaskPhase.ANALYZING_REQUEST, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
(TaskPhase.WAITING_FOR_USER, "clarification_received"): TaskPhase.ANALYZING_REQUEST,
(TaskPhase.AUTHORING_CDSL, "authoring_written"): TaskPhase.COMPILING_CDSL,
(TaskPhase.AUTHORING_CDSL, "repair_required"): TaskPhase.REPAIRING,
(TaskPhase.COMPILING_CDSL, "compiled"): TaskPhase.BUILDING,
(TaskPhase.COMPILING_CDSL, "repair_required"): TaskPhase.REPAIRING,
(TaskPhase.BUILDING, "build_completed"): TaskPhase.PUBLISHING_BEST_EFFORT,
(TaskPhase.BUILDING, "repair_required"): TaskPhase.REPAIRING,
(TaskPhase.REPAIRING, "repair_started"): TaskPhase.AUTHORING_CDSL,
(TaskPhase.PUBLISHING_BEST_EFFORT, "published"): TaskPhase.COMPLETED,
(TaskPhase.PUBLISHING_BEST_EFFORT, "failed"): TaskPhase.FAILED,
}
_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
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_TRANSITIONS.update({
(phase, "cancelled"): TaskPhase.CANCELLED
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_RETRY_RESUMABLE_PHASES = frozenset({
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
TaskPhase.DRAFTING_COMPLETION_TARGET,
TaskPhase.COMPILING_REQUIREMENTS,
TaskPhase.COMPILING_FEATURE_PLAN,
TaskPhase.SCHEDULING_FEATURE,
TaskPhase.FEATURE_PENDING,
TaskPhase.FEATURE_BUILDING,
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
TaskPhase.DRAFTING_MODELING_PLAN,
TaskPhase.AWAITING_ACTION,
TaskPhase.ACTION_PENDING,
TaskPhase.CANDIDATE_BUILDING,
TaskPhase.CANDIDATE_REVIEW,
TaskPhase.FINAL_VALIDATION,
})
_TRANSITIONS.update({
(TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase
for phase in _RETRY_RESUMABLE_PHASES
})
_TRANSITIONS.update({
(phase, "waiting_retry"): TaskPhase.WAITING_RETRY
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY}
})
for _phase in TaskPhase:
if _phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
_TRANSITIONS[(_phase, "failed")] = TaskPhase.FAILED
_TRANSITIONS[(_phase, "cancelled")] = TaskPhase.CANCELLED
if _phase in {
TaskPhase.AUTHORING_CDSL, TaskPhase.COMPILING_CDSL,
TaskPhase.BUILDING, TaskPhase.REPAIRING,
}:
_TRANSITIONS[(_phase, "publish_best_effort")] = TaskPhase.PUBLISHING_BEST_EFFORT
def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]:
"""Return a copy of the protocol transition table for architecture tests."""
return dict(_TRANSITIONS)
def retry_resume_event(state: TaskState) -> str | None:
"""Return the only lossless resume event for a parked service failure."""
if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES:
return 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, feature_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, feature_plan_path: str | None = None, feature_plan_hash: 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))
def transition(
state: TaskState,
event: str,
*,
active_revision: str | None = None,
repair_count: int | None = None,
error: ErrorCode | None = None,
requirements_path: str | None = None,
authoring_path: str | None = None,
runtime_cdsl_path: str | None = None,
compile_audit_path: str | None = None,
diagnostics_path: str | None = None,
completion_path: str | None = None,
clarification_path: str | None = None,
) -> TaskState:
target = state.retry_from_phase if state.phase == TaskPhase.FAILED and event == "resume" else _TRANSITIONS.get((state.phase, event))
if target is None:
raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?")
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
next_pending = state.pending_action if pending_action is ... else pending_action
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
next_pending = None
raise ValueError(f"Illegal single-stage transition: {state.phase.value} --{event}--> ?")
next_repairs = state.repair_count if repair_count is None else repair_count
if not 0 <= next_repairs <= 2:
raise ValueError("Single-stage repair count must be within [0, 2]")
return replace(
state,
phase=target,
version=state.version + 1,
active_revision=state.active_revision if active_revision is None else active_revision,
pending_action=next_pending,
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
repair_required=state.repair_required if repair_required is None else repair_required,
repair_count=next_repairs,
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,
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path,
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash,
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id,
retry_from_phase=state.phase if target == TaskPhase.FAILED and error in {
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, ErrorCode.STORAGE_FAILURE,
ErrorCode.RUNTIME_EXECUTION_FAILURE,
} else None,
requirements_path=state.requirements_path if requirements_path is None else requirements_path,
authoring_path=state.authoring_path if authoring_path is None else authoring_path,
runtime_cdsl_path=state.runtime_cdsl_path if runtime_cdsl_path is None else runtime_cdsl_path,
compile_audit_path=state.compile_audit_path if compile_audit_path is None else compile_audit_path,
diagnostics_path=state.diagnostics_path if diagnostics_path is None else diagnostics_path,
completion_path=state.completion_path if completion_path is None else completion_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,
)
def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None:
if supplied_head != state.working_head:
return WorkflowError(
ErrorCode.STALE_WORKING_HEAD,
"The command was bound to an obsolete working head.",
details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head},
)
return None
@@ -1,21 +0,0 @@
# Token Baseline Format
`python -m app.cad_agent.evals.live --suite release --require-live` requires
`--baseline-report` to point at a measured protocol-2 report. It is not a
fixture or an estimate. Record three independent runs of every release
scenario with the same author request configuration and runtime profile.
| Field | Requirement |
| --- | --- |
| `schema_version` | `cad.token-baseline.v1` |
| `protocol_version` | `2.0` |
| `author` | Exact `provider`, `model`, `api_style`, `reasoning_effort`, and `sampling` object from the v3 report |
| `runtime_profile_sha256` | Exact v3 `runtime_profile_sha256` |
| `measurement` | `prompt_tokens` when provider usage exists; otherwise `context_chars` |
| `scenarios[]` | One entry for every versioned release scenario, including its request SHA-256 |
| `repetitions[]` | Entries `1`, `2`, and `3`, with non-negative `author_metric` and `plan_review_metric`, plus completion, final-review, and deterministic-claim booleans |
The comparison uses `author_metric + plan_review_metric` for the v2 median and
the v3 author metric only. It fails closed when provenance differs, any
repetition is absent, completion/review/claim rates drop, plan-review calls
remain, or the median reduction is below 30%.
+1 -1
View File
@@ -1 +1 @@
"""Live, non-mocked protocol v3 release evaluations."""
"""Local evaluation helpers for the single-stage Authoring protocol."""
@@ -1,4 +1,4 @@
"""Create a resumable live-evaluation task without starting its workflow."""
"""Create an isolated single-stage task without starting its workflow."""
from __future__ import annotations
@@ -9,12 +9,12 @@ from pathlib import Path
import secrets
import sys
from app.cad_agent.composition import compose_v3
from app.cad_agent.composition import compose_cad_services
from app.settings import get_settings
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create one isolated live CAD task for resume_one_step.")
parser = argparse.ArgumentParser(description="Create one isolated Authoring CDSL evaluation task.")
parser.add_argument("--report-root", required=True, type=Path)
parser.add_argument("--prompt", required=True)
parser.add_argument("--task-id")
@@ -25,7 +25,7 @@ def main() -> int:
arguments = _arguments()
root = arguments.report_root.resolve()
settings = get_settings()
services = compose_v3(replace(
services = compose_cad_services(replace(
settings,
task_root=root / "artifacts",
conversation_root=root / "conversations",
@@ -1,423 +0,0 @@
{
"schema_version": "cad.comprehensive-prompt-fixtures.v1",
"source_document": "docs/cad-agent-v3-comprehensive-prompt-test-target.md",
"source_document_sha256": "79fbfeba41b2bf1fa37d2e90df1c4f5f06460f725f8898ae4238676bfea82d8b",
"scenarios": [
{
"id": "rectangular_mounting_plate",
"units": "mm",
"request": "生成一个CNC矩形安装板,长100毫米,宽60毫米,厚8毫米,四角圆角R6,四角各有一个直径8毫米的贯穿孔,孔中心距左右边10毫米、上下边10毫米,中心有一个直径30毫米的贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 8, "count": 4, "edge_offset_mm": 10}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "corner_radius", "description": "R6 outer corner radii"}
],
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
},
{
"id": "circular_flange_pcd",
"units": "mm",
"request": "生成一个圆形法兰盘,外径120毫米,厚度12毫米,中心贯穿孔直径40毫米,在直径90毫米的分度圆上均布6个直径8毫米的贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 6}},
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 8, "count": 6, "pitch_radius_mm": 45, "concentric_bore_diameter_mm": 40}}
],
"required_atomic_ids": [],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind", "pattern_linear"],
"required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]],
"validation_capability_gaps": [],
"max_author_turns": 52, "max_reviewer_turns": 20, "max_total_calls": 72, "max_wall_seconds": 1500, "max_total_tokens": 320000
},
{
"id": "square_flange",
"units": "mm",
"request": "生成一个方形安装法兰,长100毫米,宽100毫米,厚度12毫米,四角圆角R8,中心贯穿孔直径45毫米,四角各有一个直径10毫米的安装孔,孔中心距相邻两边各15毫米。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 45, "count": 1}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 10, "count": 4}},
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 10, "count": 4, "edge_offset_mm": 15}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "corner_radius", "description": "R8 outer corner radii"}
],
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
},
{
"id": "counterbored_mounting_plate",
"units": "mm",
"request": "生成一个矩形安装板,长120毫米,宽80毫米,厚度15毫米,四角各有一个直径9毫米的贯穿孔,每个孔顶部带直径16毫米、深5毫米的圆柱沉孔,孔中心距相邻边各12毫米。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth", "rectangular_corner_through_bore_pattern"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 15}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
{"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 16, "count": 4}},
{"claim_kind": "cylindrical_bore_depth", "expected": {"diameter_mm": 16, "count": 4, "depth_mm": 5}},
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 12}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_counterbore", "hole_wizard"],
"validation_capability_gaps": [],
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
},
{
"id": "countersunk_cover_plate",
"units": "mm",
"request": "生成一个盖板,长100毫米,宽70毫米,厚度8毫米,四角圆角R5,四角各有一个直径6.5毫米的贯穿孔,孔顶部带90度沉头,沉头最大直径12毫米。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "conical_bore"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 70}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 6.5, "count": 4}},
{"claim_kind": "conical_bore", "expected": {"small_diameter_mm": 6.5, "large_diameter_mm": 12, "included_angle_deg": 90, "count": 4}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_countersink", "hole_wizard"],
"validation_capability_gaps": [
{"id": "corner_radius", "description": "R5 outer corner radii"}
],
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
},
{
"id": "obround_slot_plate",
"units": "mm",
"request": "生成一个连接板,长140毫米,宽50毫米,厚度10毫米,两端各有一个长圆形贯穿槽,槽总长30毫米、宽12毫米,槽中心距板端20毫米,槽的长轴沿板长度方向。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "slot_dimensions", "description": "two through obround slots with 30 x 12 dimensions"},
{"id": "slot_position_orientation", "description": "20 mm end offset and lengthwise major-axis orientation"}
],
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
},
{
"id": "t_slot_test_block",
"units": "mm",
"request": "生成一个T形槽试块,长100毫米,宽50毫米,高25毫米,在顶面中心沿长度方向加工一条T形槽,槽口宽10毫米、深8毫米,槽底宽20毫米、总深15毫米,槽贯穿试块两端。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "t_slot_cross_section", "description": "10 x 8 slot mouth, 20 mm lower width and 15 mm total depth"},
{"id": "t_slot_through_orientation", "description": "top-centred T-slot through both lengthwise ends"}
],
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
},
{
"id": "rounded_rectangular_pocket",
"units": "mm",
"request": "生成一个矩形底板,长120毫米,宽80毫米,厚20毫米,在顶面中心加工一个长80毫米、宽45毫米、深12毫米的矩形口袋,口袋四角圆角R6,底部保留8毫米厚度。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 20}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "pocket_dimensions", "description": "centred 80 x 45 x 12 pocket with 8 mm remaining floor"},
{"id": "pocket_corner_radius", "description": "R6 internal pocket corners"}
],
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
},
{
"id": "two_level_pocket_plate",
"units": "mm",
"request": "生成一个CNC加工板,长140毫米,宽100毫米,厚25毫米。顶面中心先加工一个长100毫米、宽70毫米、深8毫米的矩形口袋,再在第一级口袋中心加工一个长60毫米、宽35毫米、额外深7毫米的第二级口袋,所有内角圆角R5。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "pocket_levels", "description": "100 x 70 x 8 first pocket and centred 60 x 35 x 7 additional-depth second pocket"},
{"id": "pocket_corner_radii", "description": "R5 on every internal corner"}
],
"max_author_turns": 80, "max_reviewer_turns": 30, "max_total_calls": 110, "max_wall_seconds": 2400, "max_total_tokens": 320000
},
{
"id": "cross_drilled_valve_block",
"units": "mm",
"request": "生成一个阀块试件,长80毫米,宽60毫米,高50毫米。沿长度方向加工一个直径20毫米的贯穿孔,沿宽度方向加工一个直径12毫米的贯穿孔,两个孔的轴线在零件中心相交。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "orthogonal_intersecting_through_bores"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 50}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 1}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 1}},
{"claim_kind": "orthogonal_intersecting_through_bores", "expected": {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y"}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [],
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
},
{
"id": "double_hole_linkage_arm",
"units": "mm",
"request": "生成一个机械臂双孔连杆,两个销孔中心距120毫米,连杆厚度12毫米,两端外圆直径40毫米,两个销孔直径16毫米,中间杆身最小宽度24毫米,轮廓平滑相切,中部设置三个直径14毫米的减重贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 14, "count": 3}},
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 16, "adjacent_distances_mm": [120], "tolerance_mm": 0.1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "linkage_outline", "description": "40 mm end circles, 24 mm minimum web width and smooth tangency"},
{"id": "lightening_holes", "description": "three 14 mm central through holes"}
],
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
},
{
"id": "three_hole_linkage",
"units": "mm",
"request": "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}},
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "linkage_outer_circles", "description": "32 mm outer circles with smoothly connected segments"}
],
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
},
{
"id": "l_bracket",
"units": "mm",
"request": "生成一个整体式L形角码,水平底板长80毫米、宽50毫米、厚8毫米,竖直板高60毫米、宽50毫米、厚8毫米,两板成90度。水平板上有两个直径8毫米贯穿孔,竖直板上有两个直径8毫米贯穿孔,孔左右对称。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "l_bracket_dimensions", "description": "base and upright dimensions, thicknesses and 90 degree relationship"},
{"id": "l_bracket_hole_symmetry", "description": "two symmetric 8 mm through holes on each plate"}
],
"max_author_turns": 88, "max_reviewer_turns": 32, "max_total_calls": 120, "max_wall_seconds": 2700, "max_total_tokens": 360000
},
{
"id": "ribbed_l_bracket",
"units": "mm",
"request": "生成一个整体式L形机械支架,底板长100毫米、宽60毫米、厚10毫米,竖板高80毫米、宽60毫米、厚10毫米,两板成90度。底板和竖板之间设置两个厚度8毫米的三角加强筋。底板有四个直径9毫米贯穿孔,竖板中心有一个直径30毫米贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "bracket_dimensions", "description": "100 x 60 x 10 base and 80 x 60 x 10 upright at 90 degrees"},
{"id": "triangular_ribs", "description": "two triangular 8 mm thick ribs"},
{"id": "bracket_hole_placement", "description": "four base 9 mm holes and centred upright 30 mm hole"}
],
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
},
{
"id": "u_bearing_support",
"units": "mm",
"request": "生成一个U形轴承支座,底板长100毫米、宽60毫米、厚12毫米,两侧竖耳厚12毫米、高55毫米,两个竖耳内侧间距40毫米。两个竖耳上各有一个直径20毫米的同轴贯穿孔,孔轴线距底板上表面35毫米。底板四角各有一个直径8毫米安装孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 2}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 20, "count": 2}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "u_support_dimensions", "description": "base and ear dimensions, ear spacing and 35 mm axis height"},
{"id": "base_hole_positions", "description": "four 8 mm base mounting-hole positions"}
],
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
},
{
"id": "double_lug_mount",
"units": "mm",
"request": "生成一个双耳连接座,底座长90毫米、宽60毫米、厚12毫米,底座上有两个平行耳板,每个耳板厚10毫米、高50毫米,两耳板内侧间距30毫米。两个耳板上各有一个直径16毫米的同轴贯穿销孔,孔中心距底座上表面32毫米。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 16, "count": 2}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "lug_dimensions", "description": "base, lug thickness/height, 30 mm inside spacing and 32 mm bore-axis height"}
],
"max_author_turns": 92, "max_reviewer_turns": 34, "max_total_calls": 126, "max_wall_seconds": 2700, "max_total_tokens": 380000
},
{
"id": "stepped_shaft",
"units": "mm",
"request": "生成一根阶梯轴,总长120毫米。第一段直径30毫米、长40毫米;第二段直径24毫米、长50毫米;第三段直径18毫米、长30毫米。所有轴肩过渡圆角R2,两端倒角1毫米乘45度。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 120}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 24, "count": 1}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 18, "count": 1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["revolve_add"],
"required_any_atomic_ids": ["fillet", "chamfer"],
"validation_capability_gaps": [
{"id": "shaft_segment_lengths", "description": "the 30 mm, 24 mm and 18 mm shaft sections have axial lengths 40 mm, 50 mm and 30 mm"},
{"id": "shaft_finish_features", "description": "R2 shoulders and 1 x 45 degree end chamfers"}
],
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
},
{
"id": "keyed_stepped_shaft",
"units": "mm",
"request": "生成一根阶梯传动轴,总长140毫米,中间轴段直径30毫米、长70毫米,两端轴段直径20毫米、各长35毫米。中间轴段沿轴向加工一条平键槽,键槽宽8毫米、深3.3毫米、长50毫米,所有轴肩圆角R2。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 140}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "count": 2}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["revolve_add"],
"required_any_atomic_ids": ["extrude_cut_blind", "fillet"],
"validation_capability_gaps": [
{"id": "shaft_segment_lengths", "description": "the 20 mm end sections are 35 mm each and the 30 mm middle section is 70 mm"},
{"id": "keyway", "description": "8 x 3.3 x 50 axial keyway on the middle shaft section"},
{"id": "shoulder_fillet", "description": "R2 on all shaft shoulders"}
],
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
},
{
"id": "flanged_sleeve",
"units": "mm",
"request": "生成一个机械套筒,外径50毫米,内孔直径30毫米,总长60毫米。套筒一端带外径70毫米、厚度10毫米的法兰,法兰上在直径56毫米分度圆上均布4个直径7毫米贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern", "outer_cylindrical_surface"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 60}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 70}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 50, "count": 1, "axial_span_mm": 50}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 70, "count": 1, "axial_span_mm": 10}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 7, "count": 4}},
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 7, "count": 4, "pitch_radius_mm": 28, "concentric_bore_diameter_mm": 30}}
],
"required_atomic_ids": ["revolve_add"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "flange_at_one_end", "description": "the 70 mm flange occurs at exactly one end of the coaxial sleeve, not at an interior position"}
],
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
},
{
"id": "chamfered_bushing",
"units": "mm",
"request": "生成一个圆柱轴套,外径40毫米,内孔直径25毫米,长度50毫米,两端外边缘倒角1.5毫米乘45度,两端内孔边缘倒角1毫米乘45度。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 40}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 50}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 25, "count": 1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["revolve_add"],
"required_any_atomic_ids": ["chamfer"],
"validation_capability_gaps": [
{"id": "inside_outside_chamfers", "description": "both 1.5 x 45 degree external and 1 x 45 degree internal chamfers"}
],
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
}
]
}
@@ -1,78 +0,0 @@
{
"schema_version": "cad.live-eval-fixtures.v1",
"scenarios": [
{
"id": "rectangular_plate",
"units": "mm",
"request": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80, "tolerance_mm": 0.01}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50, "tolerance_mm": 0.01}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8, "tolerance_mm": 0.01}}
],
"required_atomic_ids": ["extrude_add_blind"],
"max_author_turns": 32,
"max_reviewer_turns": 12,
"max_total_calls": 44,
"max_wall_seconds": 900,
"max_total_tokens": 120000
},
{
"id": "simple_flange",
"units": "mm",
"request": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120, "tolerance_mm": 0.01}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120, "tolerance_mm": 0.01}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.01}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"max_author_turns": 40,
"max_reviewer_turns": 16,
"max_total_calls": 56,
"max_wall_seconds": 1200,
"max_total_tokens": 160000
},
{
"id": "ribbed_mounting_plate",
"units": "mm",
"request": "Create one connected mounting plate in millimetres with a rectangular base, a vertical reinforcing rib, and four equally spaced mounting through holes. State reasonable dimensions as assumptions, then verify a single connected solid and the four-hole pattern.",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "circular_hole_pattern", "through_cylindrical_bore"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
],
"required_atomic_ids": ["extrude_add_blind", "hole_blind"],
"max_author_turns": 56,
"max_reviewer_turns": 20,
"max_total_calls": 76,
"max_wall_seconds": 1500,
"max_total_tokens": 200000
},
{
"id": "selector_finish",
"units": "mm",
"request": "Create one connected rectangular plate with an added rounded or chamfered edge selected from the current topology. Use millimetres, state assumptions, and verify the final single solid.",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["fillet", "chamfer"],
"max_author_turns": 40,
"max_reviewer_turns": 16,
"max_total_calls": 56,
"max_wall_seconds": 1200,
"max_total_tokens": 160000
}
]
}
-990
View File
@@ -1,990 +0,0 @@
"""Run the real provider, engine, reviewer and v3 state machine end to end.
``--require-live`` deliberately fails closed: missing credentials, blocked
network, a skipped model capability, a timeout, or any scenario failure emits
``LIVE_EVAL_BLOCKED``/failure details and exits non-zero.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
from datetime import datetime, timezone
from hashlib import sha256
import json
from pathlib import Path
import secrets
from statistics import median
import subprocess
import sys
from typing import Any
from app.cad_agent.application.capabilities import verify_model_capability
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.composition import compose_v3
from app.cad_agent.domain.claim_matching import contains_expected
from app.cad_agent.domain.operation_contract import canonical_hash
from app.cad_agent.domain.verifier_registry import default_registry
from app.cad_agent.evals.token_baseline import (
TokenBaselineError,
author_request_identity,
compare_token_baseline,
load_token_baseline,
profile_sha256,
validate_token_baseline_provenance,
)
from app.settings import BACKEND_ROOT, get_settings
_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({
"candidate.json",
"candidate-review.json",
"model.cdsl.json",
"model.step",
"model.glb",
"model.topology.json",
"rebuild-report.json",
"renders/render-manifest.json",
"renders/contact-sheet.jpg",
})
# A v3.2 Feature DAG publishes one atomic checkpoint after local verification.
# It deliberately has no candidate review or technical render bundle; the
# final review owns that later evidence. GLB is optional because a preview
# conversion outage must not invalidate an otherwise sound STEP checkpoint.
_FEATURE_NODE_EVIDENCE_FILES = frozenset({
"input.json",
"model.cdsl.json",
"model.step",
"model.topology.json",
"node-verification.json",
"rebuild-report.json",
})
_FAILURE_LAYERS = frozenset({
"model_format_or_decision",
"v3_contract_or_verifier",
"cdsl_expression",
"engine_execution",
"independent_visual_review",
"configuration_or_network",
})
_ERROR_FAILURE_LAYERS = {
"AUTHOR_FORMAT_INVALID": "model_format_or_decision",
"AUTHOR_DECISION_REJECTED": "model_format_or_decision",
"STALE_WORKING_HEAD": "model_format_or_decision",
"FAILED_AUTHOR_FORMAT": "model_format_or_decision",
"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED": "model_format_or_decision",
"RUNTIME_PRECONDITION_FAILED": "v3_contract_or_verifier",
"RUNTIME_CONTRACT_INVALID": "v3_contract_or_verifier",
"VERIFIER_UNAVAILABLE": "v3_contract_or_verifier",
"CLAIM_VERIFICATION_FAILED": "cdsl_expression",
"CANDIDATE_BUILD_FAILED": "engine_execution",
"CANDIDATE_REVIEW_REJECTED": "independent_visual_review",
"AUTHOR_TRANSPORT_UNAVAILABLE": "configuration_or_network",
"REVIEW_SERVICE_UNAVAILABLE": "configuration_or_network",
"RENDER_SERVICE_UNAVAILABLE": "configuration_or_network",
"STORAGE_FAILURE": "configuration_or_network",
"LIVE_EVAL_TIMEOUT": "configuration_or_network",
"FAILED_INTERNAL": "engine_execution",
}
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run live protocol-v3 CAD evaluations.")
parser.add_argument("--suite", choices=("smoke", "release", "comprehensive"), default="smoke")
parser.add_argument("--require-live", action="store_true")
parser.add_argument("--allow-skip", action="store_true")
parser.add_argument("--author-provider")
parser.add_argument("--author-model")
parser.add_argument("--review-provider")
parser.add_argument("--review-model")
parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.")
parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.")
parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.")
parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.")
parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.")
return parser.parse_args()
def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]:
fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json"
value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8"))
if fixture_name == "comprehensive.json":
source_document = str(value.get("source_document") or "")
expected_digest = str(value.get("source_document_sha256") or "")
source_path = (BACKEND_ROOT.parent / source_document).resolve()
workspace_root = BACKEND_ROOT.parent.resolve()
if (
not source_document
or workspace_root not in source_path.parents
or not source_path.is_file()
or sha256(source_path.read_bytes()).hexdigest() != expected_digest
):
raise ValueError("Comprehensive fixture is not synchronized with its source document.")
values = [item for item in value.get("scenarios") or () if isinstance(item, dict)]
values = values[:2] if suite == "smoke" else values
if not scenario_ids:
return values
requested = list(dict.fromkeys(scenario_ids))
available = {str(item.get("id") or "") for item in values}
unknown = [scenario_id for scenario_id in requested if scenario_id not in available]
if unknown:
raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}")
selected = [item for item in values if str(item.get("id") or "") in set(requested)]
return selected
def _rejection_codes(events: list[dict[str, Any]]) -> list[str]:
codes: list[str] = []
for event in events:
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
code = result.get("code") if isinstance(result, dict) else None
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "STALE_WORKING_HEAD"}:
codes.append(str(code))
return codes
def _acceptance_coverage(
scenario: dict[str, Any],
requirements_contract: dict[str, Any] | None,
) -> dict[str, Any]:
"""Assess whether a fixture's acceptance contract is representable today.
Comprehensive prompts deliberately include manufacturing relationships that
may not yet have a deterministic verifier. A healthy-looking solid is
not evidence for those relationships, so they remain explicit capability
gaps instead of silently passing a scenario.
"""
actual = [
{
"claim_kind": str(claim.get("claim_kind") or ""),
"expected": claim.get("expected") if isinstance(claim.get("expected"), dict) else {},
}
for requirement in (requirements_contract or {}).get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
]
required = {str(value) for value in scenario.get("required_claim_kinds") or ()}
expected_claims = [
{
"claim_kind": str(item.get("claim_kind") or ""),
"expected": item.get("expected") if isinstance(item.get("expected"), dict) else {},
}
for item in scenario.get("required_claims") or ()
if isinstance(item, dict) and isinstance(item.get("claim_kind"), str)
]
expected_kinds = {claim["claim_kind"] for claim in expected_claims}
expected_claims.extend(
{"claim_kind": claim_kind, "expected": {}}
for claim_kind in sorted(required - expected_kinds)
)
gaps = [
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
for item in scenario.get("validation_capability_gaps") or ()
if isinstance(item, dict)
]
missing = [
claim for claim in expected_claims
if not any(
actual_claim["claim_kind"] == claim["claim_kind"]
and _contains_business_expected(actual_claim["expected"], claim["expected"])
for actual_claim in actual
)
]
return {
"required_claim_kinds": sorted(required),
"covered_claim_kinds": sorted(required.intersection({claim["claim_kind"] for claim in actual})),
"required_claims": expected_claims,
"missing_claims": missing,
"validation_capability_gaps": gaps,
"complete": not missing and not gaps,
}
def _contains_business_expected(actual: Any, required: Any) -> bool:
"""Match fixture business values without coupling to verifier tolerances.
Tolerances are executable verifier parameters chosen within the schema's
safe range. They are not a separate user requirement and should not make
a valid generated contract fail release evaluation merely because the
author used the registry default instead of the fixture's tighter value.
"""
if isinstance(actual, dict) and isinstance(required, dict):
return all(
key in actual
and _contains_business_expected(actual[key], value)
for key, value in required.items()
if key not in {"tolerance_mm", "tolerance"}
)
return contains_expected(actual, required)
def _contains_expected(actual: Any, required: Any) -> bool:
"""Match canonical claim values in release evaluation."""
return contains_expected(actual, required)
def _capability_block_reason(*reports: dict[str, Any]) -> tuple[str, str]:
"""Classify a failed conformance probe without confusing outages for gaps.
A provider transport failure means the probe did not establish either
support or non-support. Only a complete response that violates one of the
exposed tool contracts is evidence of a model structured-output limit.
"""
messages = [
str(failure.get("message") or "").casefold()
for report in reports
for failure in report.get("failures") or ()
if isinstance(failure, dict)
]
unavailable_markers = (
"transport unavailable",
"connection",
"network",
"timeout",
"timed out",
"temporarily unavailable",
)
if any(any(marker in message for marker in unavailable_markers) for message in messages):
return "MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network"
return "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision"
def _artifact_evidence_complete(
artifact_root: Path,
revisions: list[str],
active_revision: str,
artifact_manifest: dict[str, Any] | None,
ledger: list[dict[str, Any]],
) -> bool:
"""Require every published CAD decision to retain its reviewable evidence.
A non-empty report manifest is not enough: a task could otherwise report
only a rendered contract view while silently losing the STEP, topology, or
node verification used to accept a revision. Checkpoint manifests protect
immutable build inputs and outputs; the report manifest additionally
protects the frozen contract and final independent review written later.
"""
if not revisions or not active_revision:
return False
report_files = {
str(item.get("path") or "")
for item in (artifact_manifest or {}).get("files") or ()
if isinstance(item, dict)
}
required_report_files = {
"requirements-contract.json",
f"reviews/final/{active_revision}/final-review.json",
}
if artifact_manifest is not None and not required_report_files.issubset(report_files):
return False
if not all((artifact_root / path).is_file() for path in required_report_files):
return False
feature_revisions = {
str(item.get("revision_id") or "")
for item in ledger
if isinstance(item, dict) and item.get("event") == "feature_node_verified"
}
for revision_id in sorted(set(revisions)):
revision_root = artifact_root / "revisions" / revision_id
evidence_files = (
_FEATURE_NODE_EVIDENCE_FILES
if revision_id in feature_revisions
else _LEGACY_CANDIDATE_EVIDENCE_FILES
)
required_paths = {revision_root / relative for relative in evidence_files}
manifest_path = revision_root / "manifest.json"
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
return False
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
declared = manifest.get("files") if isinstance(manifest, dict) else None
if not isinstance(declared, dict) or not evidence_files.issubset(declared):
return False
for relative, digest in declared.items():
path = revision_root / str(relative)
if not isinstance(digest, str) or len(digest) != 64 or not path.is_file():
return False
if sha256(path.read_bytes()).hexdigest() != digest:
return False
if artifact_manifest is not None:
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in evidence_files}
if not expected_report_paths.issubset(report_files):
return False
return True
def _failure_attribution(
*,
checks: dict[str, bool],
outcome: str,
events: list[dict[str, Any]],
projection: dict[str, Any],
) -> dict[str, Any] | None:
"""Return one evidence-backed failure layer required by the test target."""
if outcome == "passed":
return None
codes: list[str] = []
for event in events:
payload = event.get("payload") if isinstance(event, dict) and isinstance(event.get("payload"), dict) else {}
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
code = result.get("code") if isinstance(result, dict) else ""
if isinstance(code, str) and code:
codes.append(code)
last_error = projection.get("last_error") if isinstance(projection, dict) else ""
if isinstance(last_error, str) and last_error:
codes.append(last_error)
for code in reversed(codes):
layer = _ERROR_FAILURE_LAYERS.get(code)
if layer:
return {
"layer": layer,
"reason_code": code,
"evidence_codes": list(dict.fromkeys(codes)),
"failed_checks": [name for name, passed in checks.items() if not passed],
}
failed_checks = [name for name, passed in checks.items() if not passed]
if outcome == "validation_capability_gap" or any(name in {"required_claims", "acceptance_contract_coverage"} for name in failed_checks):
layer, reason = "v3_contract_or_verifier", "VALIDATION_CAPABILITY_GAP"
elif any(name in {"deterministic_claims_pass", "required_operations", "required_operation_alternative"} for name in failed_checks):
layer, reason = "cdsl_expression", "CAD_ACCEPTANCE_GATE_FAILED"
elif any(name in {"token_budget", "call_budget", "author_turn_budget", "reviewer_turn_budget"} for name in failed_checks):
layer, reason = "configuration_or_network", "EVALUATION_BUDGET_EXCEEDED"
elif "raw_argument_audit" in failed_checks:
layer, reason = "v3_contract_or_verifier", "RAW_ARGUMENT_AUDIT_FAILED"
elif any(name in {"immutable_artifacts", "artifact_manifest", "required_artifact_evidence", "action_ledger"} for name in failed_checks):
layer, reason = "configuration_or_network", "ARTIFACT_EVIDENCE_INCOMPLETE"
else:
layer, reason = "model_format_or_decision", "WORKFLOW_TERMINAL_STATE_MISMATCH"
return {
"layer": layer,
"reason_code": reason,
"evidence_codes": list(dict.fromkeys(codes)),
"failed_checks": failed_checks,
}
def _run_checks(
scenario: dict[str, Any],
projection: dict[str, Any],
usage: dict[str, Any],
requirements_contract: dict[str, Any] | None,
events: list[dict[str, Any]],
artifact_root: Path,
ledger: list[dict[str, Any]] | None = None,
tool_audits: list[dict[str, Any]] | None = None,
artifact_manifest: dict[str, Any] | None = None,
) -> dict[str, bool]:
records = usage.get("records") if isinstance(usage.get("records"), list) else []
author_calls = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
published = [
item for item in audit_ledger
if isinstance(item, dict) and item.get("event") in {"accepted", "feature_node_verified"}
]
revisions = [str(item.get("revision_id") or "") for item in published]
scheduled_atomic_ids = {
str(item.get("node_id") or ""): str(item.get("atomic_id") or "")
for item in audit_ledger
if isinstance(item, dict) and item.get("event") == "feature_node_scheduled"
}
operation_ids = {
str(
item.get("actual_atomic_id")
or item.get("atomic_id")
or scheduled_atomic_ids.get(str(item.get("node_id") or ""), "")
)
for item in published
}
required_operation_groups = [
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
for group in scenario.get("required_any_atomic_id_groups") or ()
if isinstance(group, list)
]
active_revision = str(projection.get("active_revision") or "")
manifest = artifact_root / "revisions" / active_revision / "manifest.json"
jsonl = artifact_root / "actions" / "action-ledger.jsonl"
final_claims = next((item.get("claim_results") for item in reversed(audit_ledger) if isinstance(item, dict) and item.get("event") == "completed" and isinstance(item.get("claim_results"), list)), [])
audit_records = tool_audits if isinstance(tool_audits, list) else []
audit_valid = bool(audit_records) and all(
isinstance(item, dict)
and isinstance(item.get("raw_arguments_hash"), str)
and len(item["raw_arguments_hash"]) == 64
and item.get("canonical_schema_valid") is True
and isinstance(item.get("state_binding"), dict)
and bool(item["state_binding"].get("phase"))
and item["state_binding"].get("binding_valid") is True
and item.get("single_allowed_call") is True
for item in audit_records
)
acceptance = _acceptance_coverage(scenario, requirements_contract)
artifact_evidence = _artifact_evidence_complete(
artifact_root,
revisions,
active_revision,
artifact_manifest,
audit_ledger,
)
return {
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
"token_budget": total_tokens <= int(scenario["max_total_tokens"]),
"call_budget": len(records) <= int(scenario["max_total_calls"]),
"author_turn_budget": len(author_calls) <= int(scenario["max_author_turns"]),
"reviewer_turn_budget": len(reviewer_calls) <= int(scenario["max_reviewer_turns"]),
"required_claims": not acceptance["missing_claims"],
"acceptance_contract_coverage": acceptance["complete"],
"required_operations": {str(value) for value in scenario.get("required_atomic_ids") or ()}.issubset(operation_ids),
"required_operation_alternative": (
(not scenario.get("required_any_atomic_ids") or bool({str(value) for value in scenario["required_any_atomic_ids"]}.intersection(operation_ids)))
and all(group.intersection(operation_ids) for group in required_operation_groups)
),
"immutable_artifacts": bool(active_revision) and manifest.is_file(),
"required_artifact_evidence": artifact_evidence,
"artifact_manifest": (
artifact_manifest is None
or (
isinstance(artifact_manifest.get("files"), list)
and bool(artifact_manifest["files"])
and all(
isinstance(item, dict)
and isinstance(item.get("path"), str)
and isinstance(item.get("sha256"), str)
and len(item["sha256"]) == 64
for item in artifact_manifest["files"]
)
)
),
"action_ledger": jsonl.is_file(),
"unique_revisions": len(revisions) == len(set(revisions)),
"deterministic_claims_pass": bool(final_claims) and all(
not isinstance(item, dict) or not item.get("deterministic") or item.get("status") == "pass"
for item in final_claims
),
"raw_argument_audit": audit_valid if tool_audits is not None else bool(records) and all(isinstance(item, dict) and isinstance(item.get("raw_arguments_hash"), str) and len(item["raw_arguments_hash"]) == 64 for item in records),
"no_schema_or_decision_rejections": not _rejection_codes(events),
}
def _git_revision() -> str:
try:
completed = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT.parent, capture_output=True,
text=True, check=True, timeout=5,
)
return completed.stdout.strip()
except (OSError, subprocess.SubprocessError):
return "unknown"
def _event_audit(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Persist event metadata without prompts, tool arguments, or tool facts."""
audit: list[dict[str, Any]] = []
for item in events:
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
audit.append({
"name": str(item.get("name") or ""), "tool": str(payload.get("tool") or ""),
"status": str(payload.get("status") or ""), "code": str(result.get("code") or payload.get("code") or ""),
"raw_arguments_hash": str(usage.get("raw_arguments_hash") or ""),
"field_errors": result.get("field_errors") if isinstance(result.get("field_errors"), list) else [],
})
return audit
def _report_tool_audits(audits: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return report-safe audit metadata without argument values or prompts."""
return [
{
"audit_id": item.get("audit_id"),
"at": item.get("at"),
"actor": item.get("actor"),
"tool": item.get("tool"),
"raw_arguments_hash": item.get("raw_arguments_hash"),
"canonical_schema_valid": item.get("canonical_schema_valid"),
"field_errors": item.get("field_errors") if isinstance(item.get("field_errors"), list) else [],
"state_binding": item.get("state_binding") if isinstance(item.get("state_binding"), dict) else {},
"returned_tool": item.get("returned_tool"),
"single_allowed_call": item.get("single_allowed_call"),
}
for item in audits
if isinstance(item, dict)
]
def _ledger_identifiers(ledger: list[dict[str, Any]]) -> dict[str, list[str]]:
"""Expose audit identifiers explicitly without copying provider payloads."""
candidate_ids = sorted({
str(item.get("candidate_id"))
for item in ledger
if isinstance(item.get("candidate_id"), str) and item.get("candidate_id")
})
revision_ids = sorted({
str(item.get("revision_id"))
for item in ledger
if isinstance(item.get("revision_id"), str) and item.get("revision_id")
})
return {"candidate_ids": candidate_ids, "revision_ids": revision_ids}
def _final_claim_results(ledger: list[dict[str, Any]]) -> list[dict[str, Any]]:
for item in reversed(ledger):
results = item.get("claim_results")
if item.get("event") == "completed" and isinstance(results, list):
return [value for value in results if isinstance(value, dict)]
return []
def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -> list[str]:
"""Keep run linkage without publishing task or provider correlation values."""
return [
canonical_hash({"task_id": task_id, "invocation_id": str(item.get("invocation_id") or "")})[:20]
for item in invocations
if isinstance(item, dict) and item.get("invocation_id")
]
def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
"""Hash reviewable CAD evidence without copying source or prompt content."""
allowed_exact = {
"requirements-contract.json",
"requirements.md",
"completion-target.md",
"completion-result.md",
}
allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-")
files: list[dict[str, str]] = []
if artifact_root.is_dir():
for path in sorted(artifact_root.rglob("*")):
if not path.is_file():
continue
relative = path.relative_to(artifact_root).as_posix()
if relative not in allowed_exact and not relative.startswith(allowed_prefixes):
continue
files.append({"path": relative, "sha256": sha256(path.read_bytes()).hexdigest()})
return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files}
def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]:
"""Summarize author-only guidance audit metadata without retaining prompts."""
records = [
item for item in usage.get("records") or ()
if isinstance(item, dict) and item.get("role") != "reviewer"
]
sections = sorted({
section_id
for item in records
for section_id in item.get("guidance_section_ids") or ()
if isinstance(section_id, str)
})
versions = sorted({
str(item.get("guidance_version") or "")
for item in records
if str(item.get("guidance_version") or "")
})
return {
"enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records),
"versions": versions,
"section_ids": sections,
"chars_total": sum(int(item.get("guidance_chars") or 0) for item in records),
"fallback_reasons": sorted({
str(item.get("guidance_fallback_reason") or "")
for item in records
if str(item.get("guidance_fallback_reason") or "")
}),
}
def _has_unsupported_capability(row: dict[str, Any]) -> bool:
"""Recognize engine-declared unsupported capability without hiding model errors."""
for event in row.get("ledger") or ():
if not isinstance(event, dict):
continue
for failure in event.get("operation_failures") or ():
if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""):
return True
if "unsupported_" in str(event.get("message") or ""):
return True
return False
def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
results = [item for item in report.get("results") or () if isinstance(item, dict)]
capability_gaps = [
item for item in results
if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item)
]
return [item for item in results if item not in capability_gaps], capability_gaps
def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]:
if not rows:
return {"eligible_runs": 0}
author_calls = [
sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
for row in rows
]
context_chars = [
sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
for row in rows
]
prompt_tokens = [
sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
for row in rows
]
failure_layers: dict[str, int] = {}
for row in rows:
layer = str((row.get("failure_attribution") or {}).get("layer") or "passed")
failure_layers[layer] = failure_layers.get(layer, 0) + 1
def rate(predicate: Any) -> float:
return sum(1 for row in rows if predicate(row)) / len(rows)
return {
"eligible_runs": len(rows),
"executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))),
"completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"),
"deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))),
"schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows),
"cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"),
"median_author_calls": median(author_calls),
"median_author_context_chars": median(context_chars),
"total_author_prompt_tokens": sum(prompt_tokens),
"median_author_prompt_tokens": median(prompt_tokens),
"failure_layers": dict(sorted(failure_layers.items())),
}
def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]:
"""Compare paired guidance-off/on runs without treating engine gaps as prompt results."""
control_rows, control_gaps = _guidance_metric_rows(control)
treatment_rows, treatment_gaps = _guidance_metric_rows(treatment)
control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows}
treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows}
paired = sorted(set(control_by_key).intersection(treatment_by_key))
control_only = sorted(set(control_by_key).difference(treatment_by_key))
treatment_only = sorted(set(treatment_by_key).difference(control_by_key))
control_pairs = [control_by_key[key] for key in paired]
treatment_pairs = [treatment_by_key[key] for key in paired]
control_metrics = _guidance_metrics(control_pairs)
treatment_metrics = _guidance_metrics(treatment_pairs)
same_runtime = (
control.get("author") == treatment.get("author")
and control.get("reviewer") == treatment.get("reviewer")
and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256")
and control.get("operation_contracts") == treatment.get("operation_contracts")
)
control_guidance = bool((control.get("author_guidance") or {}).get("enabled"))
treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled"))
same_budgets = all(
control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget")
for key in paired
)
calls_control = control_metrics.get("median_author_calls")
calls_treatment = treatment_metrics.get("median_author_calls")
calls_within_limit = (
isinstance(calls_control, (int, float))
and isinstance(calls_treatment, (int, float))
and calls_treatment <= calls_control * 1.10
)
improved = (
treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0)
or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0)
)
gates = {
"complete_pairing": bool(paired) and not control_only and not treatment_only,
"control_off_treatment_on": not control_guidance and treatment_guidance,
"same_author_reviewer_runtime_and_contracts": same_runtime,
"same_per_scenario_budgets": same_budgets,
"checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0),
"completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0),
"median_author_calls_within_ten_percent": calls_within_limit,
"model_or_cdsl_failure_improved": improved,
}
return {
"schema_version": "cad.author-guidance-comparison.v1",
"status": "passed" if all(gates.values()) else "failed",
"gates": gates,
"paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired],
"unpaired_runs": {
"control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only],
"treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only],
},
"control": control_metrics,
"treatment": treatment_metrics,
"excluded_capability_gaps": {
"control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps],
"treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps],
},
}
def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]:
try:
control = json.loads(control_path.read_text(encoding="utf-8"))
treatment = json.loads(treatment_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"}
if not isinstance(control, dict) or not isinstance(treatment, dict):
return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"}
return compare_guidance_reports(control, treatment)
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
try:
scenarios = _fixture(arguments.suite, arguments.scenarios)
except ValueError as error:
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1
if repetitions < 1:
return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"}
settings = get_settings()
if arguments.author_guidance is not None:
settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on")
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)
if review_provider.id == author_provider.id and review_model.id == author_model.id:
raise ValueError("Reviewer must differ from author")
else:
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
baseline_path = arguments.baseline_report.resolve() if arguments.baseline_report else None
if arguments.suite == "release" and baseline_path is None:
return {"status": "LIVE_EVAL_BLOCKED", "error": "TOKEN_BASELINE_REQUIRED: release requires a provenance-checked pre-v3 token baseline report."}
try:
baseline = load_token_baseline(baseline_path) if baseline_path else None
current_author_identity = author_request_identity(author_provider, author_model)
runtime_profile_hash = profile_sha256(settings.engine_root / "profile_schema.json")
except TokenBaselineError as error:
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {error}"}
if baseline is not None:
baseline_errors = validate_token_baseline_provenance(
baseline,
scenarios=scenarios,
author_identity=current_author_identity,
runtime_profile_hash=runtime_profile_hash,
)
if baseline_errors:
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {baseline_errors[0]}", "baseline_errors": baseline_errors}
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
services = compose_v3(isolated)
contracts = [
{
"atomic_id": atomic_id, "contract_hash": services.workflow.runtime.operation_contract(atomic_id)["contract_hash"],
"contract_version": services.workflow.runtime.operation_contract(atomic_id)["contract_version"],
"registry_revision": services.workflow.runtime.operation_contract(atomic_id)["registry_revision"],
}
for atomic_id in services.workflow.runtime.supported_atomic_ids()
]
verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema())
try:
author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True)
reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True)
except Exception as error:
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]}
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
error, failure_layer = _capability_block_reason(author_capability, reviewer_capability)
return {
"status": "LIVE_EVAL_BLOCKED",
"error": error,
"failure_layer": failure_layer,
"author_capability": author_capability,
"reviewer_capability": reviewer_capability,
}
results: list[dict[str, Any]] = []
for scenario in scenarios:
for repetition in range(1, repetitions + 1):
services.workflow.config = replace(
services.workflow.config,
# State transitions include local candidate recovery, so the
# loop guard is deliberately independent from the externally
# measured model-call budgets below.
max_turns=max(8, int(scenario["max_total_calls"]) * 3),
max_author_turns=int(scenario["max_author_turns"]),
max_reviewer_turns=int(scenario["max_reviewer_turns"]),
max_model_calls=int(scenario["max_total_calls"]),
)
task_id = f"cad_{secrets.token_hex(6)}"
oracle_claims = [item for item in scenario.get("required_claims") or () if isinstance(item, dict)]
if oracle_claims:
services.workflow.requirements.register_evaluation_contract_oracle(
task_id,
oracle_claims,
validation_capability_gaps=[item for item in scenario.get("validation_capability_gaps") or () if isinstance(item, dict)],
)
services.workflow.create_task(task_id, str(scenario["request"]))
events: list[dict[str, Any]] = []
started = datetime.now(timezone.utc)
try:
async with asyncio.timeout(int(scenario["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"}})
projection = services.repository.get_task_projection(task_id) or {}
usage = services.repository.usage_summary(task_id)
ledger = services.repository.ledger_events(task_id)
invocations = services.repository.invocation_records(task_id)
tool_audits = services.repository.tool_audits(task_id)
terminal = next((item["payload"] for item in reversed(events) if item["name"] == "task_terminal"), {})
artifact_root = (isolated.task_root / task_id).resolve()
artifact_manifest = _safe_artifact_manifest(artifact_root)
finished = datetime.now(timezone.utc)
checks = _run_checks(
scenario,
projection,
usage,
services.artifacts.read_requirements_contract(
task_id,
services.repository.get_state(task_id).requirements_contract_path
if services.repository.get_state(task_id) is not None
else "",
),
events,
artifact_root,
ledger,
tool_audits,
artifact_manifest,
)
success = all(checks.values())
acceptance = _acceptance_coverage(
scenario,
services.artifacts.read_requirements_contract(
task_id,
services.repository.get_state(task_id).requirements_contract_path
if services.repository.get_state(task_id) is not None
else "",
),
)
outcome = (
"passed"
if success
else "validation_capability_gap"
if not acceptance["complete"]
and all(value for key, value in checks.items() if key != "acceptance_contract_coverage")
else "failed"
)
failure_attribution = _failure_attribution(
checks=checks,
outcome=outcome,
events=events,
projection=projection,
)
results.append({
"scenario": scenario["id"], "repetition": repetition, "success": success, "outcome": outcome,
"started_at": started.isoformat(), "finished_at": finished.isoformat(),
"duration_ms": round((finished - started).total_seconds() * 1000),
"terminal": terminal, "projection": projection, "usage": usage, "checks": checks,
"acceptance_coverage": acceptance,
"failure_attribution": failure_attribution,
"guidance": _guidance_metadata(usage),
"scenario_budget": {
"max_author_turns": int(scenario["max_author_turns"]),
"max_reviewer_turns": int(scenario["max_reviewer_turns"]),
"max_total_calls": int(scenario["max_total_calls"]),
"max_total_tokens": int(scenario["max_total_tokens"]),
},
"rejection_codes": _rejection_codes(events),
"schema_rejection_count": len(_rejection_codes(events)),
"retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"),
"rollback_count": sum(1 for item in ledger if item.get("event") == "rollback"),
**_ledger_identifiers(ledger),
"final_claim_results": _final_claim_results(ledger),
"redacted_correlation_ids": _redacted_correlation_ids(task_id, invocations),
"tool_audits": _report_tool_audits(tool_audits),
"artifact_root": str(artifact_root), "action_ledger_path": str(artifact_root / "actions" / "action-ledger.jsonl"),
"artifact_manifest": artifact_manifest,
"ledger": ledger, "invocations": invocations, "event_audit": _event_audit(events),
})
token_comparison = (
compare_token_baseline(
baseline,
scenarios=scenarios,
v3_results=results,
author_identity=current_author_identity,
runtime_profile_hash=runtime_profile_hash,
)
if baseline is not None
else {"schema_version": "cad.token-comparison.v1", "status": "not_required"}
)
token_gate = all(token_comparison.get("checks", {}).values()) if baseline is not None else True
return {
"status": "passed" if results and all(item["success"] for item in results) and token_gate else "failed",
"author": {"provider": author_provider.id, "model": author_model.id},
"author_request_identity": current_author_identity,
"reviewer": {"provider": review_provider.id, "model": review_model.id},
"author_guidance": {
"enabled": settings.agent_author_guidance_enabled,
"max_chars": settings.agent_author_guidance_max_chars,
},
"author_capability": author_capability,
"reviewer_capability": reviewer_capability,
"structured_output_mode": {
"author": str(author_capability.get("mode") or ""),
"reviewer": str(reviewer_capability.get("mode") or ""),
},
"git_revision": _git_revision(),
"protocol_version": "3.2",
"runtime_profile_sha256": runtime_profile_hash,
"operation_contracts": contracts,
"verifier_registry_version": "cad.verifier-registry.v1",
"verifier_schema_hash": verifier_schema_hash,
"token_comparison": token_comparison,
"baseline_report": str(baseline_path) if baseline_path else "",
"repetitions": repetitions,
"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 = (
compare_guidance_report_paths(*arguments.compare_guidance_reports)
if arguments.compare_guidance_reports
else asyncio.run(_run(arguments, report_root))
)
except KeyboardInterrupt:
# Let an explicit operator interruption retain its normal CLI
# semantics. An external kill cannot be reported reliably either.
raise
except BaseException as error:
# A live evaluation may fail before it creates a task (for example
# during conformance). Its report is still the release gate's audit
# artifact, so an unexpected evaluator failure must not disappear.
result = {
"status": "LIVE_EVAL_BLOCKED",
"error": f"UNEXPECTED_LIVE_EVAL_ERROR: {type(error).__name__}: {str(error)[:900]}",
"failure_layer": "configuration_or_network",
}
if result["status"] == "LIVE_EVAL_BLOCKED" and arguments.allow_skip and not arguments.require_live:
result["skip_reason"] = result.get("error", "live provider access is unavailable")
result["status"] = "skipped"
result.update({"suite": arguments.suite, "require_live": arguments.require_live, "report_root": str(report_root.resolve())})
(report_root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"status": result["status"], "report": str((report_root / "report.json").resolve())}, ensure_ascii=False))
if result["status"] == "passed":
return 0
if result["status"] == "skipped":
return 0
return 2
if __name__ == "__main__":
sys.exit(main())
@@ -1,90 +0,0 @@
"""Advance one persisted event for a task in an isolated live-evaluation root.
Long real-provider evaluations can outlive a command host's execution window.
This utility takes exactly one event from ``WorkflowCoordinator.run`` and
closes the async generator after that event has been durably handled. Repeated
invocations therefore resume the same task without re-running already
persisted nodes.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
import json
from pathlib import Path
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 get_settings
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Advance one event for an isolated live CAD evaluation task.")
parser.add_argument("--report-root", required=True, type=Path)
parser.add_argument("--task-id", required=True)
parser.add_argument("--author-provider")
parser.add_argument("--author-model")
parser.add_argument("--review-provider")
parser.add_argument("--review-model")
return parser.parse_args()
async def _advance(arguments: argparse.Namespace) -> dict[str, Any]:
settings = get_settings()
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)
report_root = arguments.report_root.resolve()
services = compose_v3(replace(
settings,
task_root=report_root / "artifacts",
conversation_root=report_root / "conversations",
))
before = services.repository.get_state(arguments.task_id)
if before is None:
raise ValueError(f"Unknown task {arguments.task_id!r} in {report_root}")
runner = services.workflow.run(
task_id=arguments.task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
)
try:
name, payload = await anext(runner)
except StopAsyncIteration:
name, payload = "workflow_exhausted", {}
finally:
await runner.aclose()
after = services.repository.get_state(arguments.task_id)
return {
"task_id": arguments.task_id,
"event": {"name": name, "payload": payload},
"before": {"phase": before.phase.value, "version": before.version},
"after": {
"phase": after.phase.value if after is not None else "",
"version": after.version if after is not None else -1,
"active_revision": after.active_revision if after is not None else "",
"last_error": after.last_error.value if after is not None and after.last_error else "",
},
}
def main() -> int:
arguments = _arguments()
try:
result = asyncio.run(_advance(arguments))
except BaseException as error:
result = {"error": f"{type(error).__name__}: {str(error)[:1000]}"}
print(json.dumps(result, ensure_ascii=False))
return 2
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,55 @@
"""Aggregate local results for the `cad.single-stage.v1` evaluation suite.
The evaluator accepts already-recorded task projections and never calls a
model. This keeps rate reporting reproducible and separates it from live
provider experiments.
"""
from __future__ import annotations
from typing import Any, Iterable
def summarize(records: Iterable[dict[str, Any]]) -> dict[str, Any]:
"""Return first-pass, final, compliance, and cost counters.
A record may contain ``attempts`` with ``schema_valid`` and
``executable`` booleans, final lifecycle/revision information, requirement
target states, usage records, and an elapsed duration in milliseconds.
Missing fields are reported as zero rather than guessed.
"""
values = [item for item in records if isinstance(item, dict)]
first_schema = first_executable = final_executable = 0
targets: dict[str, int] = {key: 0 for key in ("pass", "fail", "pending", "not_applicable")}
calls = prompt_tokens = completion_tokens = duration_ms = 0
for record in values:
attempts = record.get("attempts") if isinstance(record.get("attempts"), list) else []
first = attempts[0] if attempts and isinstance(attempts[0], dict) else {}
first_schema += int(bool(first.get("schema_valid")))
first_executable += int(bool(first.get("executable")))
final_executable += int(bool(record.get("published_revision") or record.get("active_revision")))
for target in record.get("requirement_targets") or ():
state = str(target.get("status") or "pending") if isinstance(target, dict) else "pending"
targets[state if state in targets else "pending"] += 1
usage = record.get("usage") if isinstance(record.get("usage"), dict) else {}
usage_records = usage.get("records") if isinstance(usage.get("records"), list) else []
calls += len(usage_records)
for item in usage_records:
if not isinstance(item, dict):
continue
prompt_tokens += int(item.get("prompt_tokens") or 0)
completion_tokens += int(item.get("completion_tokens") or 0)
duration_ms += int(record.get("duration_ms") or 0)
total = len(values)
rate = lambda count: count / total if total else 0.0
return {
"schema_version": "cad.single-stage.eval-summary.v1",
"tasks": total,
"first_pass_schema_rate": rate(first_schema),
"first_pass_executable_rate": rate(first_executable),
"final_executable_rate": rate(final_executable),
"requirement_targets": targets,
"calls": calls,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"duration_ms": duration_ms,
}
@@ -1,297 +0,0 @@
"""Provenance-checked token baseline comparison for the v3 release gate.
The target intentionally does not accept estimated tokens or a hand-written
percentage. A v2 run must record the same fixed requests and author request
configuration before it can be compared with a v3 release report.
"""
from __future__ import annotations
from hashlib import sha256
import json
from pathlib import Path
from statistics import median
from typing import Any
from app.settings import ProviderConfig, ProviderModel
BASELINE_SCHEMA_VERSION = "cad.token-baseline.v1"
BASELINE_PROTOCOL_VERSION = "2.0"
MINIMUM_REPETITIONS = 3
TOKEN_REDUCTION_TARGET = 0.30
MEASUREMENTS = {"prompt_tokens", "context_chars"}
class TokenBaselineError(ValueError):
"""A baseline cannot prove the release token target."""
def request_sha256(request: str) -> str:
return sha256(request.encode("utf-8")).hexdigest()
def profile_sha256(profile_path: Path) -> str:
try:
return sha256(profile_path.read_bytes()).hexdigest()
except OSError as error:
raise TokenBaselineError("Runtime profile is unavailable for token-baseline provenance.") from error
def author_request_identity(provider: ProviderConfig, model: ProviderModel) -> dict[str, Any]:
"""Capture every author setting that can affect prompt-token comparison."""
return {
"provider": provider.id,
"model": model.id,
"api_style": provider.api_style,
"reasoning_effort": provider.reasoning_effort,
# Chat Completions is explicitly deterministic in StructuredModelGateway.
# Responses models use their configured/provider-default sampling mode.
"sampling": {"temperature": 0 if provider.api_style == "chat_completions" else None},
}
def load_token_baseline(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise TokenBaselineError("Token baseline report is missing or is not valid JSON.") from error
if not isinstance(payload, dict):
raise TokenBaselineError("Token baseline report must be a JSON object.")
if payload.get("schema_version") != BASELINE_SCHEMA_VERSION:
raise TokenBaselineError(f"Token baseline must use {BASELINE_SCHEMA_VERSION}.")
if payload.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
raise TokenBaselineError("Token baseline must be a measured pre-v3 (2.0) report.")
if payload.get("measurement") not in MEASUREMENTS:
raise TokenBaselineError("Token baseline measurement must be prompt_tokens or context_chars.")
return payload
def validate_token_baseline_provenance(
baseline: dict[str, Any],
*,
scenarios: list[dict[str, Any]],
author_identity: dict[str, Any],
runtime_profile_hash: str,
) -> list[str]:
"""Reject an incomparable baseline before any billable v3 API call."""
errors: list[str] = []
if baseline.get("schema_version") != BASELINE_SCHEMA_VERSION:
errors.append(f"schema_version must equal {BASELINE_SCHEMA_VERSION}")
if baseline.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
errors.append("protocol_version must equal 2.0")
if baseline.get("author") != author_identity:
errors.append("author provider/model/API mode/reasoning/sampling does not match the v3 release")
if baseline.get("runtime_profile_sha256") != runtime_profile_hash:
errors.append("runtime profile hash does not match the v3 release")
measurement = str(baseline.get("measurement") or "")
if measurement not in MEASUREMENTS:
errors.append("measurement must be prompt_tokens or context_chars")
baseline_scenarios = baseline.get("scenarios")
if not isinstance(baseline_scenarios, list):
return [*errors, "scenarios must be an array"]
by_scenario = {
str(item.get("scenario") or ""): item
for item in baseline_scenarios
if isinstance(item, dict) and item.get("scenario")
}
for scenario in scenarios:
scenario_id = str(scenario.get("id") or "")
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
baseline_scenario = by_scenario.get(scenario_id)
if not isinstance(baseline_scenario, dict):
errors.append(f"missing baseline scenario {scenario_id}")
continue
if baseline_scenario.get("request_sha256") != expected_request_hash:
errors.append(f"baseline request hash differs for {scenario_id}")
repetitions = baseline_scenario.get("repetitions")
if not isinstance(repetitions, list):
errors.append(f"baseline {scenario_id} repetitions must be an array")
continue
by_repetition = {
int(item.get("repetition")): item
for item in repetitions
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
}
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
if not expected_repetitions.issubset(by_repetition):
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
continue
for repetition in expected_repetitions:
run = by_repetition[repetition]
if _non_negative_int(run.get("author_metric")) is None or _non_negative_int(run.get("plan_review_metric")) is None:
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
for field in ("completed", "final_review_passed", "deterministic_claims_pass"):
if not isinstance(run.get(field), bool):
errors.append(f"baseline {scenario_id} repetition {repetition} has non-boolean {field}")
return errors
def compare_token_baseline(
baseline: dict[str, Any],
*,
scenarios: list[dict[str, Any]],
v3_results: list[dict[str, Any]],
author_identity: dict[str, Any],
runtime_profile_hash: str,
) -> dict[str, Any]:
"""Return a complete, fail-closed comparison for the release report."""
errors = validate_token_baseline_provenance(
baseline, scenarios=scenarios, author_identity=author_identity,
runtime_profile_hash=runtime_profile_hash,
)
measurement = str(baseline.get("measurement") or "")
baseline_scenarios = baseline.get("scenarios")
if not isinstance(baseline_scenarios, list):
errors.append("scenarios must be an array")
baseline_scenarios = []
by_scenario = {
str(item.get("scenario") or ""): item
for item in baseline_scenarios
if isinstance(item, dict) and item.get("scenario")
}
current_by_scenario: dict[str, list[dict[str, Any]]] = {}
for result in v3_results:
if isinstance(result, dict) and isinstance(result.get("scenario"), str):
current_by_scenario.setdefault(result["scenario"], []).append(result)
baseline_tokens: list[int] = []
current_tokens: list[int] = []
baseline_completed: list[bool] = []
current_completed: list[bool] = []
baseline_final_review: list[bool] = []
current_final_review: list[bool] = []
baseline_deterministic: list[bool] = []
current_deterministic: list[bool] = []
per_scenario: list[dict[str, Any]] = []
for scenario in scenarios:
scenario_id = str(scenario.get("id") or "")
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
baseline_scenario = by_scenario.get(scenario_id)
if not isinstance(baseline_scenario, dict):
errors.append(f"missing baseline scenario {scenario_id}")
continue
if baseline_scenario.get("request_sha256") != expected_request_hash:
errors.append(f"baseline request hash differs for {scenario_id}")
continue
repetitions = baseline_scenario.get("repetitions")
if not isinstance(repetitions, list) or len(repetitions) < MINIMUM_REPETITIONS:
errors.append(f"baseline {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
continue
current = current_by_scenario.get(scenario_id, [])
if len(current) < MINIMUM_REPETITIONS:
errors.append(f"v3 {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
continue
baseline_by_repetition = {
int(item.get("repetition")) : item
for item in repetitions
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
}
current_by_repetition = {
int(item.get("repetition")): item
for item in current
if isinstance(item.get("repetition"), int)
}
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
if not expected_repetitions.issubset(baseline_by_repetition):
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
continue
if not expected_repetitions.issubset(current_by_repetition):
errors.append(f"v3 {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
continue
scenario_baseline_tokens: list[int] = []
scenario_current_tokens: list[int] = []
for repetition in sorted(expected_repetitions):
baseline_run = baseline_by_repetition[repetition]
author_tokens = _non_negative_int(baseline_run.get("author_metric"))
plan_review_tokens = _non_negative_int(baseline_run.get("plan_review_metric"))
if author_tokens is None or plan_review_tokens is None:
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
continue
current_run = current_by_repetition[repetition]
usage = current_run.get("usage") if isinstance(current_run.get("usage"), dict) else {}
v3_author_tokens = _v3_author_metric(usage, measurement)
if v3_author_tokens is None:
errors.append(f"v3 {scenario_id} repetition {repetition} has invalid author {measurement} usage")
continue
scenario_baseline_tokens.append(author_tokens + plan_review_tokens)
scenario_current_tokens.append(v3_author_tokens)
baseline_completed.append(bool(baseline_run.get("completed")))
current_completed.append(str((current_run.get("projection") or {}).get("phase") or "") == "COMPLETED")
baseline_final_review.append(bool(baseline_run.get("final_review_passed")))
current_final_review.append(str((current_run.get("terminal") or {}).get("lifecycle") or "") == "completed")
baseline_deterministic.append(bool(baseline_run.get("deterministic_claims_pass")))
checks = current_run.get("checks") if isinstance(current_run.get("checks"), dict) else {}
current_deterministic.append(bool(checks.get("deterministic_claims_pass")))
if len(scenario_baseline_tokens) == MINIMUM_REPETITIONS and len(scenario_current_tokens) == MINIMUM_REPETITIONS:
baseline_tokens.extend(scenario_baseline_tokens)
current_tokens.extend(scenario_current_tokens)
per_scenario.append({
"scenario": scenario_id,
"baseline_median_metric": median(scenario_baseline_tokens),
"v3_median_author_metric": median(scenario_current_tokens),
})
baseline_median = median(baseline_tokens) if baseline_tokens else None
current_median = median(current_tokens) if current_tokens else None
reduction = (1 - current_median / baseline_median) if baseline_median and current_median is not None else None
baseline_valid = not errors
checks = {
"baseline_valid": baseline_valid,
"median_metric_reduction": baseline_valid and reduction is not None and reduction >= TOKEN_REDUCTION_TARGET,
"completion_rate_not_lower": baseline_valid and _rate(current_completed) >= _rate(baseline_completed) if baseline_completed and current_completed else False,
"final_review_rate_not_lower": baseline_valid and _rate(current_final_review) >= _rate(baseline_final_review) if baseline_final_review and current_final_review else False,
"deterministic_claim_rate_not_lower": baseline_valid and _rate(current_deterministic) >= _rate(baseline_deterministic) if baseline_deterministic and current_deterministic else False,
"plan_review_calls_removed": baseline_valid and all(
not str(record.get("tool") or "").startswith("modeling_plan")
for result in v3_results if isinstance(result, dict)
for record in ((result.get("usage") or {}).get("records") or [])
if isinstance(record, dict)
),
}
return {
"schema_version": "cad.token-comparison.v1",
"baseline_protocol_version": baseline.get("protocol_version"),
"measurement": measurement,
"baseline_median_metric": baseline_median,
"v3_median_author_metric": current_median,
"median_metric_reduction": reduction,
"target_median_metric_reduction": TOKEN_REDUCTION_TARGET,
"baseline_completion_rate": _rate(baseline_completed),
"v3_completion_rate": _rate(current_completed),
"baseline_final_review_rate": _rate(baseline_final_review),
"v3_final_review_rate": _rate(current_final_review),
"baseline_deterministic_claim_rate": _rate(baseline_deterministic),
"v3_deterministic_claim_rate": _rate(current_deterministic),
"per_scenario": per_scenario,
"errors": errors,
"checks": checks,
}
def _non_negative_int(value: Any) -> int | None:
return value if isinstance(value, int) and value >= 0 else None
def _v3_author_metric(usage: dict[str, Any], measurement: str) -> int | None:
records = usage.get("records")
if not isinstance(records, list):
return None
author_records = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
if not author_records:
return None
if measurement == "prompt_tokens" and not all(item.get("usage_available") is True for item in author_records):
return None
field = "prompt_tokens" if measurement == "prompt_tokens" else "context_chars"
values = [
_non_negative_int(item.get(field))
for item in author_records
]
return sum(value for value in values if value is not None) if all(value is not None for value in values) else None
def _rate(values: list[bool]) -> float | None:
return sum(values) / len(values) if values else None
-164
View File
@@ -1,164 +0,0 @@
"""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())
+6 -78
View File
@@ -5,60 +5,13 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from .domain.state import TaskPhase, TaskState
from .domain.state import TaskState
class AdapterUnavailable(RuntimeError):
"""A bounded external-service outage; handlers must preserve checkpoints."""
@dataclass(frozen=True, slots=True)
class AuthorGuidanceSelection:
"""Non-authoritative author context selected from the local guidance corpus."""
version: str = ""
section_ids: tuple[str, ...] = ()
content: str = ""
enabled: bool = False
fallback_reason: str = ""
def usage_metadata(self) -> dict[str, object]:
return {
"guidance_version": self.version,
"guidance_section_ids": list(self.section_ids),
"guidance_chars": len(self.content),
"guidance_enabled": self.enabled,
"guidance_fallback_reason": self.fallback_reason,
}
class AuthorGuidance(Protocol):
"""Select bounded local author guidance without interpreting user intent."""
def select(
self,
*,
phase: TaskPhase,
atomic_id: str,
repair_required: bool,
supported_atomic_ids: tuple[str, ...],
) -> AuthorGuidanceSelection: ...
class NullAuthorGuidance:
"""Compatibility default that retains the pre-guidance author prompt."""
def select(
self,
*,
phase: TaskPhase,
atomic_id: str,
repair_required: bool,
supported_atomic_ids: tuple[str, ...],
) -> AuthorGuidanceSelection:
return AuthorGuidanceSelection(fallback_reason="guidance_not_configured")
@dataclass(frozen=True, slots=True)
class InvocationRecord:
invocation_id: str
@@ -68,7 +21,7 @@ class InvocationRecord:
@dataclass(frozen=True, slots=True)
class CandidateStage:
class StagingRevision:
stage_id: str
output_dir: str
@@ -106,38 +59,21 @@ class TaskRepository(Protocol):
class ArtifactStore(Protocol):
def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ...
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ...
def read_source_index(self, task_id: str) -> dict[str, str]: ...
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
def read_source_requirements(self, task_id: str) -> str: ...
def source_image_paths(self, task_id: str) -> list[str]: ...
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ...
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ...
def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ...
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: ...
def stage_output_dir(self, task_id: str, stage_id: str) -> str: ...
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: ...
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: ...
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: ...
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: ...
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
class CadRuntime(Protocol):
def supported_atomic_ids(self) -> tuple[str, ...]: ...
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ...
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, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]: ...
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
def create_preview(self, output_dir: str) -> dict[str, Any]: ...
def render_review_bundle(self, output_dir: str) -> dict[str, Any]: ...
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], 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]]]: ...
@@ -146,13 +82,5 @@ class ModelGateway(Protocol):
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ...
class ReviewGateway(Protocol):
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: ...
class VerifierExecutor(Protocol):
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: ...
class EventPublisher(Protocol):
async def publish(self, event: dict[str, Any]) -> None: ...
+22 -123
View File
@@ -7,13 +7,11 @@ from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from app.models.contracts import ChatRequest, ConversationPatch
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
from app.services.agent_service import AgentService
from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
from app.services.attachments import attachment_record, classify_upload, extract_document_text
from app.services.image_processing import image_metadata
from app.services.review_renderer import renderer_status
from app.settings import get_settings
@@ -57,12 +55,6 @@ async def config() -> dict[str, Any]:
for model in provider.models
],
})
try:
settings.resolve_review_model()
renderer_ready, renderer_detail = renderer_status()
review_error = "" if renderer_ready else renderer_detail
except ValueError as error:
review_error = str(error)
return {
"default_provider": settings.default_provider_id,
"default_model": settings.llm_model,
@@ -71,8 +63,6 @@ async def config() -> dict[str, Any]:
"configured": settings.llm_configured,
"library_samples": library.count(),
"autonomous_generation": settings.autonomous_generation,
"review_configured": not review_error,
"review_error": review_error,
}
@@ -123,7 +113,7 @@ async def upload_conversation_attachment(
if current is None:
raise HTTPException(status_code=404, detail="Conversation not found")
active_task_id = str(current.get("current_task_id") or "")
active_task = agent.v3.repository.get_task_projection(active_task_id) if active_task_id else None
active_task = agent.cad.repository.get_task_projection(active_task_id) if active_task_id else None
if str((active_task or {}).get("lifecycle") or "") == "running":
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
kind = classify_upload(filename, file.content_type or "", len(data))
@@ -145,101 +135,25 @@ async def upload_conversation_attachment(
async def read_task(task_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
task = agent.v3.repository.get_task_projection(safe_id)
task = agent.cad.repository.get_task_projection(safe_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
state = agent.v3.repository.get_state(safe_id)
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["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.feature_plan_path) if state is not None and state.feature_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 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["feature_plan"] = agent.v3.artifacts.read_json(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
task["feature_plan_path"] = state.feature_plan_path if plan_path and plan_path.is_file() else ""
task["feature_plan_hash"] = state.feature_plan_hash if state is not None else ""
if isinstance(task["feature_plan"], dict):
plan = FeaturePlan.model_validate(task["feature_plan"])
statuses = FeatureScheduler(plan, agent.v3.repository.ledger_events(safe_id)).statuses()
node_evidence = {str(item.get("node_id") or ""): item for item in task.get("feature_nodes") or () if isinstance(item, dict)}
task["feature_nodes"] = [
{
"node_id": str(node.get("node_id") or ""), "intent": str(node.get("intent") or ""),
"atomic_id": str(node.get("atomic_id") or ""), "priority": node.get("priority"),
"depends_on": node.get("depends_on") or [], "claim_ids": node.get("claim_ids") or [],
"status": statuses.get(str(node.get("node_id") or ""), "pending"),
**node_evidence.get(str(node.get("node_id") or ""), {}),
}
for node in task["feature_plan"].get("nodes") or () if isinstance(node, dict)
]
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)
task["preview_revision"] = str(task.get("active_revision") or "")
state = agent.cad.repository.get_state(safe_id)
task["requirements_analysis"] = agent.cad.artifacts.read_json(safe_id, state.requirements_path) if state and state.requirements_path else None
task["authoring_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.authoring_path) if state and state.authoring_path else None
task["runtime_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.runtime_cdsl_path) if state and state.runtime_cdsl_path else None
task["compile_audit"] = agent.cad.artifacts.read_json(safe_id, state.compile_audit_path) if state and state.compile_audit_path else None
task["diagnostics"] = agent.cad.artifacts.read_json(safe_id, state.diagnostics_path) if state and state.diagnostics_path else None
task["claim_report"] = agent.cad.artifacts.read_json(safe_id, "documents/claim-report.json")
result_path = agent.cad.artifacts.artifact_path(safe_id, state.completion_path) if state and state.completion_path else None
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path and result_path.is_file() else None
task["usage"] = agent.cad.repository.usage_summary(safe_id)
return JSONResponse(task)
def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]:
"""Project frozen claims with the newest committed verification evidence.
This is API-only data derived from SQLite-backed ledger entries and the
state-referenced frozen contract. It never parses Markdown or exposes a
staged candidate as a task checkpoint.
"""
latest: dict[str, dict[str, Any]] = {}
for event in reversed(ledger if isinstance(ledger, list) else []):
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed", "feature_node_verified", "final_visual_reviewed"}:
continue
values = event.get("claim_results")
if not isinstance(values, list):
values = event.get("coverage")
if not isinstance(values, list):
continue
for value in values:
if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest:
latest[value["claim_id"]] = value
result: list[dict[str, Any]] = []
for requirement in (contract or {}).get("requirements") or ():
if not isinstance(requirement, dict):
continue
for claim in requirement.get("acceptance_claims") or ():
if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str):
continue
evidence = latest.get(claim["claim_id"], {})
result.append({
"requirement_id": str(requirement.get("requirement_id") or ""),
"claim_id": claim["claim_id"],
"claim_kind": str(claim.get("claim_kind") or ""),
"deterministic": claim.get("verification_mode") == "deterministic",
"status": str(evidence.get("status") or "pending"),
"evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {},
})
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:
@@ -281,19 +195,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
try:
safe_id = safe_task_id(task_id)
path = agent.v3.artifacts.artifact_path(safe_id, artifact_path)
path = agent.cad.artifacts.artifact_path(safe_id, artifact_path)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if not path.is_file():
raise HTTPException(status_code=404, detail="Artifact not found")
task = agent.v3.repository.get_task_projection(safe_id) or {}
task = agent.cad.repository.get_task_projection(safe_id) or {}
parts = artifact_path.split("/")
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else ""
published_revision = str(task.get("published_revision") or "")
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
if not revision_id:
# The task directory also contains candidate staging, agent audit and
# frozen-input files. None of those are a public artifact surface.
if artifact_path == "completion-result.md" and str(task.get("completion_path") or "") == artifact_path:
return FileResponse(path, filename=path.name)
raise HTTPException(status_code=403, detail="This task artifact is not public")
if revision_id == published_revision:
@@ -302,30 +216,15 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
f"revisions/{revision_id}/model.step",
f"revisions/{revision_id}/model.glb",
f"revisions/{revision_id}/rebuild-report.json",
f"revisions/{revision_id}/build-diagnostics.json",
f"revisions/{revision_id}/renders/render-manifest.json",
}
if artifact_path not in published_paths:
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
return FileResponse(path, filename=path.name)
v32_checkpoint_paths = {
f"revisions/{revision_id}/model.cdsl.json",
f"revisions/{revision_id}/model.step",
f"revisions/{revision_id}/model.glb",
f"revisions/{revision_id}/rebuild-report.json",
}
verified_revisions = {
str(item.get("revision_id") or "")
for item in task.get("revisions") or ()
if isinstance(item, dict) and item.get("status") == "success"
}
# Each v3.2 node revision is immutable and manifest-published. Make those
# checkpoints inspectable from the DAG while keeping every staging input,
# rejected candidate and arbitrary task artifact private.
if task.get("schema_version") == "3.2" and revision_id in verified_revisions and artifact_path in v32_checkpoint_paths:
return FileResponse(path, media_type="model/gltf-binary" if path.suffix == ".glb" else None, headers={"Content-Disposition": "inline" if path.suffix == ".glb" else f"attachment; filename={path.name}"})
# A legacy running task may render its active checkpoint in the browser,
# but cannot expose any other checkpoint artifact or failed-task preview.
# A running task can render its active executable prefix, but its staging
# inputs and diagnostics remain private until publication.
if (
str(task.get("lifecycle") or "") != "running"
or revision_id != active_revision
+105 -440
View File
@@ -1,5 +1,4 @@
"""HTTP/SSE delivery adapter for the protocol v3 workflow."""
"""HTTP/SSE delivery adapter for the single-stage CAD protocol."""
from __future__ import annotations
import asyncio
@@ -10,8 +9,7 @@ import secrets
from typing import Any
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability
from app.cad_agent.composition import V3Services, compose_v3
from app.cad_agent.composition import CadServices, compose_cad_services
from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.state import TaskPhase, transition
from app.models.contracts import ChatMessage
@@ -25,480 +23,174 @@ def text_from_message(message: ChatMessage) -> str:
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
def _response_language(text: str) -> str:
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
latin = sum(1 for char in text if char.isascii() and char.isalpha())
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
_EVENT_LABELS = {
"image_observation": "参考图片观察",
"requirements_document_ready": "需求文档已冻结",
"completion_target_ready": "完成目标已冻结",
"requirements_compiled": "需求合同已编译",
"modeling_plan_ready": "建模计划已冻结",
"completion_result_ready": "完成结果已就绪",
"model_protocol_check": "模型协议检查",
"action_selection": "动作选择",
"tool_call": "建模工具",
"candidate_result": "候选构建",
"candidate_review": "候选独立复核",
"final_review": "最终独立复核",
"requirements_ready": "需求分析",
"authoring_cdsl_ready": "完整 CDSL",
"cdsl_compiled": "CDSL 编译",
"build_result": "CAD 构建",
"repair_started": "CDSL 修复",
"task_terminal": "生成任务",
"state_changed": "任务状态",
}
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
def _progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
lifecycle = str(payload.get("lifecycle") or "")
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running")
status = "error" if lifecycle == "failed" or payload.get("status") in {"failed", "repair_required"} else "waiting" if lifecycle == "waiting_for_user" else "success" if lifecycle == "completed" else "running"
return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status}
def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
part_id = str(part.get("id") or "")
if part_id:
for index, existing in enumerate(parts):
if str(existing.get("id") or "") == part_id:
parts[index] = part
return
parts.append(part)
class AgentService:
"""Delivery boundary: no CAD state transitions or provider calls live here."""
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
self.settings = settings
self.store = store # Conversation/attachment store, not v3 CAD state.
self.library = library
self.v3: V3Services = compose_v3(settings)
self.settings, self.store, self.library = settings, store, library
self.cad: CadServices = compose_cad_services(settings)
self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
async def resume_running_tasks(self) -> None:
task_ids = self.v3.repository.running_task_ids()
try:
author_provider, author_model = self.settings.resolve_model(None, None)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
str(error),
retryable=False,
)
return
try:
author_capability, reviewer_capability = await asyncio.gather(
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
role="author",
),
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
role="reviewer",
),
)
except Exception as error:
# Startup recovery must never bypass a production capability gate.
# Connectivity failures are recoverable, but they may not leave a
# task falsely marked running without a worker.
await self._park_startup_tasks(
task_ids,
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE,
str(error),
retryable=True,
)
return
if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"):
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_PROTOCOL_CHECK_PENDING,
"Model protocol check is temporarily unavailable.",
retryable=True,
)
return
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
"Selected author or reviewer did not pass the v3 conformance suite.",
retryable=False,
)
return
if not self.settings.resume_running_tasks_on_startup:
return
for task_id in task_ids:
if task_id in self._autonomous_runs:
continue
self._autonomous_runs[task_id] = asyncio.create_task(
self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)),
name=f"resume-autonomous-cad-v3-{task_id}",
)
async def _park_startup_tasks(
self,
task_ids: list[str],
error: ErrorCode,
message: str,
*,
retryable: bool,
) -> None:
"""Make a failed startup gate durable instead of leaving phantom runs."""
event_name = "waiting_retry" if retryable else "failed_model_capability"
for task_id in task_ids:
state = self.v3.repository.get_state(task_id)
if state is None or task_id in self._autonomous_runs:
continue
try:
next_state = transition(
state,
"waiting_retry" if retryable else "failed",
error=error,
)
except ValueError:
continue
if self.v3.repository.compare_and_swap(next_state, events=[{
"event": event_name,
"code": error.value,
"message": message[:1000],
"startup_recovery": True,
}]):
await self.v3.outbox.dispatch_pending(task_id=task_id)
async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None:
try:
async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer):
# A resumed task has no active SSE client, but its durable
# state events must still leave the transactional outbox.
await self.v3.outbox.dispatch_pending(task_id=task_id)
provider, model = self.settings.resolve_model(None, None)
except ValueError:
return
author = ModelIdentity(provider.id, model.id)
for task_id in self.cad.repository.running_task_ids():
if task_id not in self._autonomous_runs:
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, author), name=f"resume-cad-single-stage-{task_id}")
async def _consume_discarding(self, task_id: str, author: ModelIdentity) -> None:
try:
async for _name, _payload in self.cad.workflow.run(task_id=task_id, author=author):
await self.cad.outbox.dispatch_pending(task_id=task_id)
finally:
# Flush a final transition emitted immediately before the worker
# exits, such as WAITING_RETRY or FAILED_INTERNAL.
await self.v3.outbox.dispatch_pending(task_id=task_id)
await self.cad.outbox.dispatch_pending(task_id=task_id)
self._autonomous_runs.pop(task_id, None)
async def cancel(self, task_id: str) -> dict[str, Any] | None:
state = self.v3.repository.get_state(task_id)
state = self.cad.repository.get_state(task_id)
if state is None:
return None
if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED)
if self.v3.repository.compare_and_swap(cancelled, events=[{
"event": "task_cancelled",
"from_phase": state.phase.value,
"active_revision": state.active_revision,
"checkpoint_preserved": bool(state.active_revision),
}]):
await self.v3.outbox.dispatch_pending(task_id=task_id)
# Persist the terminal transition before interrupting the coroutine.
# A concurrent worker will fail its optimistic CAS instead of reviving
# the task after the caller has requested cancellation.
self.cad.repository.compare_and_swap(cancelled, events=[{"event": "task_cancelled", "active_revision": state.active_revision}])
await self.cad.outbox.dispatch_pending(task_id=task_id)
running = self._autonomous_runs.pop(task_id, None)
if running and not running.done():
running.cancel()
return self.v3.repository.get_task_projection(task_id)
return self.cad.repository.get_task_projection(task_id)
async def resume_retry(self, task_id: str) -> dict[str, Any] | None:
"""Explicitly restart one durably parked infrastructure retry.
``WAITING_FOR_USER`` is deliberately excluded: it requires new user
input, while this endpoint is only the controlled recovery route for
bounded provider/render/storage failures.
"""
state = self.v3.repository.get_state(task_id)
state = self.cad.repository.get_state(task_id)
if state is None:
return None
if state.phase.value != "WAITING_RETRY":
raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint")
running = self._autonomous_runs.get(task_id)
if running is not None and not running.done():
if state.phase != TaskPhase.FAILED or state.retry_from_phase is None:
raise ValueError("Only a retryable failed CAD task can be resumed")
if task_id in self._autonomous_runs and not self._autonomous_runs[task_id].done():
raise ValueError("The CAD task is already running")
author_provider, author_model = self.settings.resolve_model(None, None)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
author_capability = await verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
role="author",
)
reviewer_capability = await verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
role="reviewer",
)
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
if not self.v3.workflow.resume(task_id):
raise ValueError("The CAD task no longer has a recoverable retry checkpoint")
self._autonomous_runs[task_id] = asyncio.create_task(
self._consume_discarding(
task_id,
ModelIdentity(author_provider.id, author_model.id),
ModelIdentity(review_provider.id, review_model.id),
),
name=f"resume-autonomous-cad-v3-{task_id}",
)
return self.v3.repository.get_task_projection(task_id)
provider, model = self.settings.resolve_model(None, None)
if not self.cad.workflow.resume(task_id):
raise ValueError("The CAD task no longer has a retry checkpoint")
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, ModelIdentity(provider.id, model.id)), name=f"resume-cad-single-stage-{task_id}")
return self.cad.repository.get_task_projection(task_id)
async def stream(
self,
messages: list[ChatMessage],
conversation_id: str | None,
selected_task_id: str | None,
provider_id: str | None = None,
model_id: str | None = None,
viewer_context: list[dict[str, Any]] | None = None,
) -> AsyncIterator[bytes]:
async def stream(self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None) -> AsyncIterator[bytes]:
del viewer_context
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
if latest_user is None or not text_from_message(latest_user):
latest = next((item for item in reversed(messages) if item.role == "user"), None)
if latest is None or not text_from_message(latest):
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
yield event("done", {})
return
request = text_from_message(latest_user)
request = text_from_message(latest)
conversation = self.store.ensure_conversation(conversation_id)
selected = str(selected_task_id or conversation.get("current_task_id") or "")
current = self.v3.repository.get_task_projection(selected) if selected else None
resumed_task_id = ""
if current and str(current.get("lifecycle") or "") == "waiting_for_user":
state = self.v3.repository.get_state(selected)
if state is not None:
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state)
fields = [
{"path": "/requirements/clarification", "message": str(question)}
for question in terminal.get("questions") or ()
if str(question).strip()
]
fields.extend(
{"path": "/requirements", "message": str(issue)}
for issue in terminal.get("issues") or ()
if str(issue).strip()
)
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
yield event("cad_error", {
"stage": "request",
"message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]),
"fieldErrors": fields,
"taskId": selected,
"blockerType": terminal.get("blockerType"),
"userActionRequired": terminal.get("userActionRequired"),
})
yield event("done", {})
return
resumed_task_id = selected
if current and str(current.get("lifecycle") or "") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
current = self.cad.repository.get_task_projection(selected) if selected else None
resumed = False
if current and current.get("lifecycle") == "waiting_for_user":
resumed = self.cad.workflow.resume_with_user_clarification(selected, request, message_id=latest.id)
if not resumed:
yield event("cad_error", {"stage": "request", "message": "The CAD task cannot apply this clarification."})
yield event("done", {})
return
if current and current.get("lifecycle") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成后才能继续。"})
yield event("done", {})
return
# A terminal task is immutable; follow-up text creates a new task.
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
if not resumed_task_id:
task_id = selected if resumed else f"cad_{secrets.token_hex(6)}"
if not resumed:
try:
source_blocks, image_inputs = self._task_inputs(conversation, request)
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
self.cad.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
except ValueError as error:
yield event("cad_error", {"stage": "request", "message": str(error)})
yield event("done", {})
return
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."})
self.store.append_conversation_message(conversation["conversation_id"], latest.model_dump(), task_id)
yield event("progress", _progress("task_started", {"taskId": task_id, "message": "CAD task started."}))
try:
author_provider, author_model = self.settings.resolve_model(provider_id, model_id)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
provider, model = self.settings.resolve_model(provider_id, model_id)
except ValueError as error:
state = self.v3.repository.get_state(task_id)
if state is not None:
state = self.cad.repository.get_state(task_id)
if state:
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
self.v3.repository.compare_and_swap(failed, events=[{
"event": "model_configuration_invalid",
"message": str(error)[:1000],
"issues": [str(error)[:1000]],
}])
terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False}
yield event("task_terminal", terminal)
self.cad.repository.compare_and_swap(failed, events=[{"event": "model_configuration_invalid", "message": str(error)}])
yield event("cad_error", {"stage": "configuration", "message": str(error)})
yield event("done", {})
return
author = ModelIdentity(provider.id, model.id)
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
parts: list[dict[str, Any]] = []
sequence = 0
async def consume() -> None:
nonlocal sequence
async def dispatch_state_events() -> None:
"""Publish state notifications only through the outbox.
The dispatcher preserves at-least-once semantics. The SSE
event ID derives from the durable outbox row, so reconnecting
clients can safely de-duplicate a delivery replay.
"""
nonlocal sequence
for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id):
sequence += 1
event_name = str(outbox_event.get("event") or "state_changed")
payload = {
"taskId": task_id,
"eventId": f"outbox_{outbox_event['event_id']}",
"sequence": sequence,
"timestamp": now_iso(),
"outboxEvent": event_name,
"message": event_name.replace("_", " "),
}
await queue.put(("progress", _visible_progress("state_changed", payload)))
try:
capability_terminal = await self._ensure_task_capabilities(
task_id,
ModelIdentity(author_provider.id, author_model.id),
ModelIdentity(review_provider.id, review_model.id),
queue,
)
if capability_terminal is not None:
sequence += 1
capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()}
_upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)})
await queue.put(("task_terminal", capability_terminal))
return
async for name, payload in self.v3.workflow.run(
task_id=task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
):
sequence += 1
decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()}
_upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed":
_upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": {
"stage": "generation",
"message": str(decorated.get("message") or "CAD autonomous generation failed."),
"tool": str(decorated.get("tool") or ""),
"fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [],
}})
async for name, payload in self.cad.workflow.run(task_id=task_id, author=author):
decorated = {**payload, "taskId": task_id, "eventId": f"{task_id}_{secrets.token_hex(4)}", "timestamp": now_iso()}
parts.append({"type": "data-cad-progress", "id": decorated["eventId"], "data": _progress(name, decorated)})
await queue.put((name, decorated))
await dispatch_state_events()
except Exception as error:
sequence += 1
terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False}
_upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)})
_upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}})
await queue.put(("task_terminal", terminal))
await self.cad.outbox.dispatch_pending(task_id=task_id)
if name == "task_terminal" and decorated.get("lifecycle") == "completed":
projection = self.cad.repository.get_task_projection(task_id) or {}
result = self._result_payload(task_id, projection)
if result is not None:
await queue.put(("cad_result", result))
finally:
self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id)
self._autonomous_runs.pop(task_id, None)
await queue.put(None)
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}")
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"cad-single-stage-{task_id}")
while True:
try:
item = await asyncio.wait_for(queue.get(), timeout=15)
except asyncio.TimeoutError:
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
continue
item = await queue.get()
if item is None:
break
name, payload = item
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
yield event("cad_error", {
"stage": "generation",
"message": str(payload.get("message") or "CAD autonomous generation failed."),
"tool": str(payload.get("tool") or ""),
"fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [],
})
if name == "task_terminal" and payload.get("lifecycle") == "failed":
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD generation failed."), "code": payload.get("code")})
yield event(name, payload)
yield event("done", {})
async def _ensure_task_capabilities(
self,
task_id: str,
author: ModelIdentity,
reviewer: ModelIdentity,
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None],
) -> dict[str, Any] | None:
roles = (("author", author), ("reviewer", reviewer))
cached = {
role: cached_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
provider_id=model.provider_id,
model_id=model.model_id,
role=role,
)
for role, model in roles
}
unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")]
if unsupported:
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
missing = [(role, model) for role, model in roles if cached[role] is None]
if not missing:
@staticmethod
def _result_payload(task_id: str, projection: dict[str, Any]) -> dict[str, Any] | None:
revision_id = str(projection.get("published_revision") or projection.get("active_revision") or "")
revisions = projection.get("revisions") if isinstance(projection.get("revisions"), list) else []
revision = next((item for item in revisions if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
if not isinstance(revision, dict):
return None
state = self.v3.repository.get_state(task_id)
if state is None:
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False}
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
if not self.v3.repository.compare_and_swap(waiting, events=[{
"event": "model_protocol_check_pending",
"code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value,
"message": "模型协议检查中,完成后将自动继续。",
}]):
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False}
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"}))
try:
results = await asyncio.gather(*(
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=model.provider_id,
model_id=model.model_id,
role=role,
)
for role, model in missing
))
except Exception as error:
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False}
unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")]
if unavailable:
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False}
unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")]
if unsupported:
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
if not self.v3.workflow.resume(task_id):
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False}
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"}))
return None
def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]:
state = self.v3.repository.get_state(task_id)
if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
self.v3.repository.compare_and_swap(failed, events=[{
"event": "model_protocol_unsupported",
"message": message,
"issues": [message],
}])
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False}
required = ("cdsl_path", "step_path", "glb_path", "report_path")
if not all(isinstance(revision.get(path), str) and revision[path] for path in required):
return None
return {
"taskId": task_id,
"revisionId": revision_id,
"cdslPath": revision["cdsl_path"],
"stepPath": revision["step_path"],
"glbPath": revision["glb_path"],
"reportPath": revision["report_path"],
"summary": str(revision.get("summary") or "CDSL CAD model"),
"referenceIds": list(revision.get("reference_ids") or []),
"engine": str(revision.get("engine") or "cdsl_only"),
"lifecycle": str(projection.get("lifecycle") or "completed"),
}
def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
"""Freeze message paragraphs and attachment blocks before task creation."""
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
image_inputs: list[dict[str, str]] = []
conversation_id = str(conversation.get("conversation_id") or "")
@@ -509,51 +201,24 @@ class AgentService:
continue
if str(attachment.get("conversation_id") or "") != conversation_id:
raise ValueError("Attachment does not belong to this conversation")
attachment_id = str(attachment.get("id") or "")
relative_path = str(attachment.get("path") or "")
expected_digest = str(attachment.get("sha256") or "")
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest):
attachment_id, relative_path, digest = str(attachment.get("id") or ""), str(attachment.get("path") or ""), str(attachment.get("sha256") or "")
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", digest):
raise ValueError("Attachment metadata is incomplete")
binary_path = self.store.conversation_attachment_path(conversation_id, relative_path)
if not binary_path.is_file():
raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}")
if sha256(binary_path.read_bytes()).hexdigest() != expected_digest:
raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}")
binary = self.store.conversation_attachment_path(conversation_id, relative_path)
if not binary.is_file() or sha256(binary.read_bytes()).hexdigest() != digest:
raise ValueError(f"Attachment is unavailable: {attachment.get('name') or attachment_id}")
kind = str(attachment.get("kind") or "")
if kind == "document":
extracted_path = str(attachment.get("extracted_path") or "")
if not extracted_path:
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
text_path = self.store.conversation_attachment_path(conversation_id, extracted_path)
text_path = self.store.conversation_attachment_path(conversation_id, str(attachment.get("extracted_path") or ""))
if not text_path.is_file():
raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}")
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
text = text_path.read_text(encoding="utf-8", errors="replace").strip()
elif kind == "image":
# The binary is integrity-checked above. Do not claim that a
# text-only author has interpreted visual content; the source
# block is still a stable, reviewable attachment reference.
text = (
f"Visual attachment {attachment.get('name') or attachment_id} "
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
"It is a visual reference and requires explicit visual verification."
)
image_inputs.append({
"path": str(binary_path),
"mime": str(attachment.get("mime") or "image/*"),
"sha256": expected_digest,
})
text = f"Visual attachment {attachment.get('name') or attachment_id} (SHA-256 {digest})."
image_inputs.append({"path": str(binary), "mime": str(attachment.get("mime") or "image/*"), "sha256": digest})
else:
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
if not text:
raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}")
blocks.append({
"text": text,
"attachment": {
"attachment_id": attachment_id,
"name": str(attachment.get("name") or attachment_id),
"kind": kind,
"mime": str(attachment.get("mime") or "application/octet-stream"),
"sha256": expected_digest,
},
})
blocks.append({"text": text, "attachment": {"attachment_id": attachment_id, "name": str(attachment.get("name") or attachment_id), "kind": kind, "mime": str(attachment.get("mime") or "application/octet-stream"), "sha256": digest}})
return blocks, image_inputs
+17 -3
View File
@@ -47,7 +47,7 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict):
raise RuntimeError("The local engine schema has no v3 operation contract registry")
raise RuntimeError("The local engine schema has no operation contract registry")
return schema
@@ -108,10 +108,11 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
def validate_cdsl_shape(cdsl: dict[str, Any], engine: Any) -> None:
"""Validate the static Runtime CDSL contract without resolving topology."""
if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object")
if cdsl.get("schema") != "cad.cdsl.llm.v1":
if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}:
raise ValueError("Unsupported CDSL schema")
_validate_cdsl_json_schema(cdsl, engine)
part_id = str(cdsl.get("part_id") or "")
@@ -125,6 +126,14 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
sketches = cdsl.get("geometry", {}).get("sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list):
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
if cdsl.get("schema") == "cad.runtime.v1":
bodies = cdsl.get("bodies")
if not isinstance(bodies, list) or not bodies:
raise ValueError("Runtime CDSL requires server-assigned bodies")
body_ids = [str(body.get("id") or "") for body in bodies if isinstance(body, dict)]
body_names = [str(body.get("name") or "") for body in bodies if isinstance(body, dict)]
if len(body_ids) != len(bodies) or len(body_ids) != len(set(body_ids)) or len(body_names) != len(set(body_names)):
raise ValueError("Runtime CDSL bodies must have unique server IDs and local names")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
try:
@@ -185,6 +194,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError("Polygon profiles require vertices")
elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
"""Validate static CDSL and the current executable topology semantics."""
validate_cdsl_shape(cdsl, engine)
try:
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
except Exception as error:
@@ -1,8 +1,8 @@
"""Deterministic, CPU-only CAD technical renders for visual review.
"""Deterministic, CPU-only CAD technical render bundles.
OpenCascade computes exact visible/hidden edges from the revision STEP file.
Pillow rasterizes the resulting technical drawings. Neither stage needs a web
browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence
browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts
consistent on macOS, Linux, and Windows workers.
"""
@@ -26,7 +26,7 @@ VISIBLE_EDGE_RGB = (34, 54, 69)
HIDDEN_EDGE_RGB = (142, 157, 170)
class ReviewRenderError(RuntimeError):
class RenderBundleError(RuntimeError):
"""The fixed-view renderer was unavailable or produced incomplete evidence."""
@@ -34,7 +34,7 @@ def renderer_status() -> tuple[bool, str]:
"""Verify that the pure-Python/OCC renderer dependencies are importable."""
try:
_render_modules()
except ReviewRenderError as error:
except RenderBundleError as error:
return False, str(error)
return True, ""
@@ -45,7 +45,7 @@ def _render_modules() -> tuple[Any, Any, Any]:
pillow_draw = importlib.import_module("PIL.ImageDraw")
import_step = importlib.import_module("build123d").import_step
except (ImportError, AttributeError) as error:
raise ReviewRenderError(
raise RenderBundleError(
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
) from error
return pillow_image, pillow_draw, import_step
@@ -73,7 +73,7 @@ def _shape_bounds(shape: Any) -> list[float]:
box = shape.bounding_box()
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
if not all(math.isfinite(value) for value in bounds):
raise ReviewRenderError("STEP review source has invalid bounds")
raise RenderBundleError("STEP render source has invalid bounds")
return bounds
@@ -128,7 +128,7 @@ def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
points = [point for edge in edges for point in _edge_points(edge, 0.5)]
if not points:
raise ReviewRenderError("Hidden-line projection produced no drawable edges")
raise RenderBundleError("Hidden-line projection produced no drawable edges")
xs, ys = zip(*points)
return min(xs), max(xs), min(ys), max(ys)
@@ -242,7 +242,7 @@ def _render_view(
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
)
except Exception as error:
raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
visible_edges, hidden_edges = list(visible), list(hidden)
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
rendered = _rasterize(
@@ -254,12 +254,12 @@ def _render_view(
intentional_crop=target_extent > 0,
)
if not rendered["diagnostics"]["valid"]:
raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
"""Create compact whole-model evidence for routine reviewer calls."""
"""Create compact whole-model images for published CAD artifacts."""
pillow_image, pillow_draw, _ = _render_modules()
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
if not canonical:
@@ -283,27 +283,27 @@ def render_checkpoint(
*,
step_path: Path,
output_dir: Path,
review_targets: list[dict[str, Any]] | None = None,
detail_targets: list[dict[str, Any]] | None = None,
include_canonical: bool = True,
) -> dict[str, Any]:
"""Render STEP geometry into stable canonical and bounded node-detail views."""
del settings
ready, detail = renderer_status()
if not ready:
raise ReviewRenderError(detail)
raise RenderBundleError(detail)
if not step_path.is_file():
raise ReviewRenderError(f"STEP review source is missing: {step_path.name}")
raise RenderBundleError(f"STEP render source is missing: {step_path.name}")
_, _, import_step = _render_modules()
try:
shape = import_step(str(step_path))
except Exception as error:
raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error
raise RenderBundleError(f"Unable to read STEP render source: {error}") from error
bounds = _shape_bounds(shape)
output_dir.mkdir(parents=True, exist_ok=True)
jobs: list[tuple[str, dict[str, Any] | None]] = []
if include_canonical:
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3]))
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3]))
views = [
_render_view(
shape=shape,
@@ -317,14 +317,14 @@ def render_checkpoint(
]
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
if include_canonical and canonical != set(CANONICAL_VIEWS):
raise ReviewRenderError("Python review renderer did not produce every canonical view")
raise RenderBundleError("Python render bundle generator did not produce every canonical view")
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
manifest = {
"schema_version": "cad.render-manifest.v2",
"renderer": "python-occ-hlr-pillow",
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
"review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
"render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
"contact_sheet_path": contact_sheet_path,
"views": views,
}
@@ -343,22 +343,22 @@ def render_section(
"""Create an actual OpenCascade section drawing, not a clipped viewport.
It intentionally uses the same deterministic Pillow raster path as the
seven canonical review views. The output contains compact contour evidence
suitable for a multimodal author without sending a STEP file or full B-rep.
seven canonical render views. The output contains compact contour evidence
suitable for inspection without sending a STEP file or full B-rep.
"""
del settings
ready, detail = renderer_status()
if not ready:
raise ReviewRenderError(detail)
raise RenderBundleError(detail)
if not step_path.is_file():
raise ReviewRenderError(f"STEP section source is missing: {step_path.name}")
raise RenderBundleError(f"STEP section source is missing: {step_path.name}")
origin = _number_list(origin_mm, size=3)
direction = _number_list(normal, size=3)
if origin is None or direction is None:
raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers")
raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers")
length = math.sqrt(sum(value * value for value in direction))
if length <= 1e-9:
raise ReviewRenderError("Section normal must not be zero")
raise RenderBundleError("Section normal must not be zero")
normal_unit = [value / length for value in direction]
_, _, import_step = _render_modules()
try:
@@ -372,9 +372,9 @@ def render_section(
section = b3d.section(shape, section_by=plane)
edges = list(section.edges())
except Exception as error:
raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error
raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error
if not edges:
raise ReviewRenderError("Section plane does not intersect the model")
raise RenderBundleError("Section plane does not intersect the model")
# Choose a deterministic right-handed in-plane frame. Projecting exact
# OCC section edges into this frame preserves holes and internal contours.
@@ -406,7 +406,7 @@ def render_section(
if len(line) >= 2:
projected.append(line)
if not projected:
raise ReviewRenderError("Section operation produced no drawable contours")
raise RenderBundleError("Section operation produced no drawable contours")
xs = [point[0] for line in projected for point in line]
ys = [point[1] for line in projected for point in line]
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Conversation and attachment storage for the v3 delivery boundary.
"""Conversation and attachment storage for the CAD delivery boundary.
CAD task state deliberately does not live here. Protocol v3 owns mutable task
CAD task state deliberately does not live here. The Authoring protocol owns mutable task
state in ``SqliteTaskRepository`` and immutable task artifacts in
``FileArtifactStore``.
"""
-41
View File
@@ -65,15 +65,6 @@ class Settings:
llm_timeout_s: float
default_provider_id: str
providers: tuple[ProviderConfig, ...]
review_provider_id: str = ""
review_model_id: str = ""
agent_tool_calls_per_cycle: int = 12
agent_consecutive_no_progress_limit: int = 6
agent_format_error_repeat_limit: int = 3
agent_context_char_limit: int = 14000
agent_author_guidance_enabled: bool = True
agent_author_guidance_max_chars: int = 3600
agent_render_cache: bool = True
autonomous_generation: bool = True
resume_running_tasks_on_startup: bool = True
@@ -102,29 +93,6 @@ class Settings:
raise ValueError("The selected model is not enabled for this provider")
return provider, model
def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]:
"""Return the independently configured visual reviewer, never an author fallback."""
provider_id = self.review_provider_id
if not provider_id:
raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider")
provider = self.provider_for(provider_id)
if provider is None:
raise ValueError("The configured visual review provider is unavailable")
model_id = self.review_model_id or ""
if not model_id:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model")
model = provider.model(model_id)
if model is None or not model.vision:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
return provider, model
def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]:
"""Require the candidate judge to be a separately configured model."""
provider, model = self.resolve_review_model()
if provider.id == author_provider.id and model.id == author_model.id:
raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model")
return provider, model
def _reasoning_effort(value: str) -> str:
effort = value.strip().lower()
@@ -207,15 +175,6 @@ def get_settings() -> Settings:
llm_timeout_s=llm_timeout_s,
default_provider_id=default_provider_id,
providers=providers,
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True),
agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))),
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
autonomous_generation=True,
# Production instances recover durable runs by default. Test workers
# can disable this before startup to guarantee they touch only tasks