From 7533b298f1a23ed5d4c8a63982b2984258426b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Mon, 31 Aug 2026 14:16:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 10 +- backend/app/main.py | 15 + backend/app/services/agent_service.py | 224 +- .../services/autonomous_cdsl_generation.py | 2560 ++++++++++++++++- backend/app/services/cdsl_authoring_schema.py | 403 +++ backend/app/services/cdsl_fragment.py | 351 ++- backend/app/services/engine_service.py | 4 +- backend/app/services/storage.py | 68 +- backend/app/services/visual_review.py | 250 +- backend/app/settings.py | 4 + backend/engine/cdsl_engine/cdsl_schema.json | 2 +- .../engine/cdsl_engine/profile_schema.json | 2 +- backend/engine/cdsl_engine/rebuild.py | 7 +- backend/engine/cdsl_engine/runtime.py | 18 + .../tests/test_autonomous_cdsl_generation.py | 1000 ++++++- backend/tests/test_plan_actions.py | 225 ++ backend/tests/test_sphere_add.py | 13 +- backend/tests/test_visual_review.py | 95 +- frontend/package-lock.json | 1646 ++++++++++- frontend/package.json | 5 + frontend/src/app/api/chat/route.ts | 27 +- frontend/src/app/api/tasks/[taskId]/route.ts | 7 + frontend/src/app/globals.css | 34 +- frontend/src/components/agent-studio.tsx | 51 +- frontend/src/components/agent-thread.tsx | 19 +- frontend/src/components/cad-message-parts.tsx | 24 +- frontend/src/components/rich-content.tsx | 57 + frontend/src/lib/cad-stream.test.ts | 50 + frontend/src/lib/cad-stream.ts | 40 +- frontend/src/lib/cad-types.ts | 18 + 30 files changed, 6976 insertions(+), 253 deletions(-) create mode 100644 backend/app/services/cdsl_authoring_schema.py create mode 100644 backend/tests/test_plan_actions.py create mode 100644 frontend/src/components/rich-content.tsx diff --git a/backend/.env b/backend/.env index 23bb85e6..a92ffe62 100644 --- a/backend/.env +++ b/backend/.env @@ -2,7 +2,7 @@ # CDSL_DEFAULT_PROVIDER=deepseek # CDSL_DEFAULT_MODEL=deepseek-v4-flash CDSL_DEFAULT_PROVIDER=openai -CDSL_DEFAULT_MODEL=gpt-5.5 +CDSL_DEFAULT_MODEL=gpt-5.4-mini # DeepSeek. Fill in your own API key below. CDSL_LLM_BASE_URL=https://api.deepseek.com/v1 @@ -12,15 +12,15 @@ CDSL_LLM_TIMEOUT_S=90 CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp # Final autonomous-task publication uses this independent vision reviewer. -CDSL_REVIEW_PROVIDER=deepseek -CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp +CDSL_REVIEW_PROVIDER=openai +CDSL_REVIEW_MODEL=gpt-5.5 # Optional OpenAI provider. Comma-separate enabled models; list vision models # separately so image attachments can be routed safely. CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1 CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229 -CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna -CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna +CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna,gpt-5.4-mini +CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna,gpt-5.4-mini # Supported values are model- and endpoint-dependent: low, medium, high. # gpt-5.5 defaults to medium, but setting it explicitly keeps requests stable. CDSL_OPENAI_REASONING_EFFORT=medium diff --git a/backend/app/main.py b/backend/app/main.py index 6d1eb370..1539b1b3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -156,10 +156,25 @@ async def read_task(task_id: str) -> JSONResponse: task["requirements_markdown"] = requirements or None completion_checklist = store.read_completion_checklist(task["task_id"]) task["completion_checklist_markdown"] = completion_checklist or None + modeling_plan = store.read_modeling_plan(task["task_id"]) + task["modeling_plan_markdown"] = modeling_plan or None + task["modeling_plan_review"] = store.read_modeling_plan_review(task["task_id"]) task["agent_state"] = store.read_agent_state(task["task_id"]) return JSONResponse(task) +@app.delete("/v1/tasks/{task_id}") +async def cancel_task(task_id: str) -> JSONResponse: + try: + safe_id = safe_task_id(task_id) + task = await agent.cancel(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") + return JSONResponse(task) + + @app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}") async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: from fastapi.responses import FileResponse diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 4649a9b0..0e5cc553 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import base64 -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable, Callable import json import secrets from pathlib import Path @@ -17,7 +17,7 @@ from app.services.autonomous_cdsl_generation import AutonomousCdslGenerationRunn from app.services.library import CdslLibrary from app.services.review_renderer import renderer_status from app.services.sse import event -from app.services.storage import WorkspaceStore +from app.services.storage import WorkspaceStore, now_iso from app.settings import ProviderConfig, ProviderModel, Settings @@ -25,6 +25,68 @@ 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 = { + "requirements_document": "冻结需求", "completion_checklist": "完成清单", "completion_audit": "完成审计", + "modeling_plan": "建模计划", "modeling_plan_review": "计划独立复核", + "tool_call": "建模工具", "candidate_result": "候选构建", "candidate_review": "候选独立复核", + "geometry_diagnostic": "几何诊断", "geometry_conclusion": "几何结论", "step_review": "步骤审查", + "checkpoint": "构建检查点", "rollback": "回滚检查点", "final_review": "最终视觉复核", "task_terminal": "生成任务", +} + + +def _event_status(name: str, payload: dict[str, Any]) -> str: + if name == "task_terminal": + lifecycle = str(payload.get("lifecycle") or "") + if lifecycle == "failed": + return "error" + if lifecycle == "cancelled": + return "cancelled" + return "success" + review = payload.get("review") if isinstance(payload.get("review"), dict) else {} + if (name == "modeling_plan_review" and str(review.get("verdict") or "") == "revise") or ( + name == "candidate_review" and str(review.get("verdict") or "") == "reject" + ) or ( + name == "final_review" and str(review.get("verdict") or "") == "repair" + ): + return "error" + return str(payload.get("status") or "running") + + +def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]: + data = dict(payload) + data.update({"step": name, "label": _EVENT_LABELS.get(name, name), "status": _event_status(name, payload)}) + return data + + +def _append_text_part(parts: list[dict[str, Any]], text: str) -> None: + if not text: + return + if parts and parts[-1].get("type") == "text": + parts[-1]["text"] = str(parts[-1].get("text") or "") + text + else: + parts.append({"type": "text", "text": text}) + + +def _upsert_data_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 _StreamingUnsupported(RuntimeError): + pass + + def conversation_user_context(conversation: dict[str, Any]) -> list[dict[str, str]]: """Return durable user intent from the whole conversation. @@ -131,6 +193,19 @@ class AgentService: self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"resume-autonomous-cdsl-{task_id}") + async def cancel(self, task_id: str) -> dict[str, Any] | None: + task = self.store.read_task(task_id) + if task is None: + return None + running = self._autonomous_runs.pop(task_id, None) + if running and not running.done(): + running.cancel() + if str(task.get("lifecycle") or "") == "running": + task = self.store.finish_generation(task_id, lifecycle="cancelled", failure={ + "schema_version": "cad.autonomous-cancelled.v1", "stage": "cancelled", "message": "CAD generation was cancelled by the user.", + }) + return task + async def stream( self, messages: list[ChatMessage], @@ -214,34 +289,67 @@ class AgentService: if isinstance(attachment, dict) and str(attachment.get("id") or "") ] self.store.start_generation(task_id, request=user_text) + yield event("progress", { + "taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", + "message": "CAD 任务已启动。" if _response_language(user_text) == "Chinese" else "CAD task started.", + }) queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() - runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete) + assistant_parts: list[dict[str, Any]] = [] + sequence = 0 + + async def on_text_delta(text: str) -> None: + # Text is persisted and streamed through the same ordered queue as + # tool events, so a refresh produces the exact same transcript. + nonlocal sequence + sequence += 1 + _append_text_part(assistant_parts, text) + await queue.put(("text_delta", { + "text": text, "taskId": task_id, "eventId": f"{task_id}_{sequence}_text_delta", + "sequence": sequence, "timestamp": now_iso(), + })) + + runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete, on_text_delta=on_text_delta) assistant_id = f"assistant_{secrets.token_hex(8)}" async def consume() -> None: - assistant_parts: list[dict[str, Any]] = [] + nonlocal sequence terminal = "" try: async for name, payload in runner.run( task_id=task_id, request=user_text, conversation_id=conversation["conversation_id"], provider=provider, model=model, initial_messages=initial_messages, frozen_attachment_ids=frozen_attachment_ids, already_started=True, ): + sequence += 1 + decorated = dict(payload) + decorated.setdefault("taskId", task_id) + decorated.setdefault("eventId", f"{task_id}_{sequence}_{name}") + decorated.update({ + "sequence": sequence, + "timestamp": now_iso(), + }) if name == "cad_result": - assistant_parts.append({"type": "data-cad-result", "data": payload}) + _upsert_data_part(assistant_parts, {"type": "data-cad-result", "id": decorated["eventId"], "data": decorated}) elif name == "agent_thinking": visible = str(payload.get("message") or "") if visible: - assistant_parts.append({"type": "text", "text": visible}) + # Streaming completions already delivered this + # content through on_text_delta; only persist the + # full response for the compatibility path. + if not assistant_parts or assistant_parts[-1].get("type") != "text" or visible not in str(assistant_parts[-1].get("text") or ""): + _append_text_part(assistant_parts, visible + "\n\n") elif name == "task_terminal": terminal = str(payload.get("lifecycle") or "") if terminal == "failed": - assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}}) - await queue.put((name, payload)) + _upsert_data_part(assistant_parts, {"type": "data-cad-error", "id": decorated["eventId"], "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}}) + elif name != "text_delta": + _upsert_data_part(assistant_parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)}) + await queue.put((name, decorated)) except Exception as error: self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)}) terminal = "failed" payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)} - assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}}) + error_id = f"{task_id}_{sequence + 1}_cad_error" + assistant_parts.append({"type": "data-cad-error", "id": error_id, "data": {"stage": "generation", "message": str(error)}}) await queue.put(("task_terminal", payload)) finally: if terminal == "completed" and not assistant_parts: @@ -252,14 +360,20 @@ class AgentService: self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cdsl-{task_id}") while True: - item = await queue.get() + try: + item = await asyncio.wait_for(queue.get(), timeout=15) + except asyncio.TimeoutError: + yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()}) + continue 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 自主生成失败。")}) elif name == "agent_thinking": - yield event("text_delta", {"text": str(payload.get("message") or "")}) + message = str(payload.get("message") or "") + if message: + yield event("text_delta", {"text": message + "\n\n"}) else: yield event(name, payload) yield event("done", {}) @@ -278,6 +392,94 @@ class AgentService: provider: ProviderConfig, model: ProviderModel, required_tool_name: str | None = None, + *, + on_text_delta: Callable[[str], Awaitable[None]] | None = None, + ) -> dict[str, Any]: + if on_text_delta is None: + return await self._complete_once(messages, tools, provider, model, required_tool_name) + try: + return await self._complete_stream(messages, tools, provider, model, required_tool_name, on_text_delta) + except _StreamingUnsupported: + await on_text_delta("当前模型不支持工具流式,已切换为兼容模式。\n\n") + response = await self._complete_once(messages, tools, provider, model, required_tool_name) + content = str((((response.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "") + if content: + await on_text_delta(content + "\n\n") + # Prevent the runner from emitting this same full response again. + choice = ((response.get("choices") or [{}])[0] or {}).get("message") or {} + return {"choices": [{"message": {**choice, "content": ""}}]} + + async def _complete_stream( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + provider: ProviderConfig, + model: ProviderModel, + required_tool_name: str | None, + on_text_delta: Callable[[str], Awaitable[None]], + ) -> dict[str, Any]: + tool_choice: str | dict[str, Any] = "auto" + if required_tool_name: + tool_choice = {"type": "function", "function": {"name": required_tool_name}} + payload: dict[str, Any] = { + "model": model.id, "messages": messages, "tools": tools, + "tool_choice": tool_choice, "temperature": 0.1, "stream": True, + } + payload.update(provider.chat_completion_options) + headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} + try: + async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client: + async with client.stream("POST", f"{provider.base_url}/chat/completions", headers=headers, json=payload) as response: + if response.status_code in {400, 404, 405, 415, 422}: + detail = (await response.aread()).decode(errors="replace")[:400] + raise _StreamingUnsupported(detail) + if response.status_code >= 400: + raise RuntimeError(f"LLM request failed ({response.status_code}): {(await response.aread()).decode(errors='replace')[:800]}") + if "text/event-stream" not in str(response.headers.get("content-type") or ""): + raise _StreamingUnsupported("provider returned a non-stream response") + tool_calls: dict[int, dict[str, Any]] = {} + async for line in response.aiter_lines(): + if not line.startswith("data:"): + continue + raw = line[5:].strip() + if raw == "[DONE]": + break + try: + chunk = json.loads(raw) + except json.JSONDecodeError: + continue + choices = chunk.get("choices") if isinstance(chunk, dict) else None + delta = choices[0].get("delta") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {} + if not isinstance(delta, dict): + continue + text = str(delta.get("content") or delta.get("reasoning_content") or "") + if text: + await on_text_delta(text) + for call in delta.get("tool_calls") or []: + if not isinstance(call, dict): + continue + index = int(call.get("index") or 0) + current = tool_calls.setdefault(index, {"id": str(call.get("id") or f"call_{index}"), "type": "function", "function": {"name": "", "arguments": ""}}) + if call.get("id"): + current["id"] = str(call["id"]) + function = call.get("function") if isinstance(call.get("function"), dict) else {} + if function.get("name"): + current["function"]["name"] += str(function["name"]) + if function.get("arguments"): + current["function"]["arguments"] += str(function["arguments"]) + except _StreamingUnsupported: + raise + except (httpx.HTTPError, asyncio.TimeoutError) as error: + raise RuntimeError(f"LLM streaming connection failed: {error}") from error + return {"choices": [{"message": {"content": "", "tool_calls": [tool_calls[key] for key in sorted(tool_calls)]}}]} + + async def _complete_once( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + provider: ProviderConfig, + model: ProviderModel, + required_tool_name: str | None = None, ) -> dict[str, Any]: tool_choice: str | dict[str, Any] = "auto" if required_tool_name: diff --git a/backend/app/services/autonomous_cdsl_generation.py b/backend/app/services/autonomous_cdsl_generation.py index 9414a68c..67126b29 100644 --- a/backend/app/services/autonomous_cdsl_generation.py +++ b/backend/app/services/autonomous_cdsl_generation.py @@ -1,10 +1,10 @@ """Autonomous, append-only CDSL authoring loop. -The author model is intentionally not asked to satisfy a provider-specific -strict JSON schema. It observes a frozen requirements document and real CAD -state, then submits one small JSON fragment at a time. Validation happens at -the only trust boundary that matters: the complete, materialised CDSL is -rebuilt by the CDSL-only engine in a staging directory before it can become a +The author model observes a frozen requirements document and real CAD state, +then submits one small operation-specific fragment at a time. The active +Runtime contract supplies the provider tool schema; validation still happens +at the trust boundary where the complete materialised CDSL is rebuilt by the +CDSL-only engine in an isolated staging directory before it can become a revision. """ @@ -30,6 +30,11 @@ from app.services.cdsl_fragment import ( autonomous_selector_tokens, materialize_autonomous_fragment, ) +from app.services.cdsl_authoring_schema import ( + build_operation_fragment_schema, + canonical_fragment_example, + operation_contract_hash, +) from app.services.engine_service import ( feature_atomic_contract, load_engine, @@ -38,13 +43,14 @@ from app.services.engine_service import ( topology_snapshot, validate_cdsl, ) +from app.services.library import CdslLibrary from app.services.review_renderer import ReviewRenderError, render_checkpoint, render_section from app.services.storage import WorkspaceStore, now_iso, read_json, write_json -from app.services.visual_review import VisualReviewError, review_candidate_batch, review_checkpoint +from app.services.visual_review import VisualReviewError, review_candidate_batch, review_checkpoint, review_modeling_plan from app.settings import ProviderConfig, ProviderModel, Settings -Completion = Callable[[list[dict[str, Any]], list[dict[str, Any]], ProviderConfig, ProviderModel, str | None], Awaitable[dict[str, Any]]] +Completion = Callable[..., Awaitable[dict[str, Any]]] class AutonomousGenerationError(RuntimeError): @@ -57,6 +63,141 @@ def _is_author_quota_error(error: Exception) -> bool: return "llm request failed (429)" in message and ("quota" in message or "exhausted" in message) +def _response_language(text: str) -> str: + """Choose the language for visible Agent prose from the user's request.""" + 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" + + +def _visible_error(message: str, request: str) -> str: + if _response_language(request) != "Chinese": + return message + translations = { + "Each fragment feature must declare a runtime atomic_id": "候选片段无效:每个特征都必须声明运行时 atomic_id。", + "write_requirements_document must be the first tool call": "必须先写入并冻结需求文档,之后才能调用其他建模工具。", + "write_completion_checklist must be completed before modelling": "必须先写完并冻结完成清单,之后才能开始建模。", + } + return translations.get(message, message) + + +def _fragment_argument(arguments: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """Return a structured fragment, with a read-only legacy compatibility path. + + The public tool schema uses ``fragment`` as an object. Older persisted + tests/tasks may still call the retired ``fragment_json`` string spelling; + accepting it here avoids breaking recovery while keeping it out of the + model-facing contract. + """ + value = arguments.get("fragment") + if isinstance(value, dict): + return deepcopy(value), False + legacy = arguments.get("fragment_json") + if isinstance(legacy, str) and legacy.strip(): + try: + parsed = json.loads(legacy) + except json.JSONDecodeError as error: + raise AutonomousGenerationError( + f"FRAGMENT_JSON_INVALID at line {error.lineno}, column {error.colno}: {error.msg}" + ) from error + if isinstance(parsed, dict): + return parsed, True + raise AutonomousGenerationError("FRAGMENT_OBJECT_REQUIRED: fragment must be a JSON object") + + +def _best_effort_fragment(arguments: dict[str, Any]) -> dict[str, Any] | None: + """Read a fragment for diagnostics without raising a second parse error.""" + value = arguments.get("fragment") + if isinstance(value, dict): + return value + legacy = arguments.get("fragment_json") + if isinstance(legacy, str): + try: + value = json.loads(legacy) + except json.JSONDecodeError: + return None + return value if isinstance(value, dict) else None + return None + + +def _fragment_diagnostic(message: str, *, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + """Normalize authoring/runtime failures for audit, UI, and future repair agents.""" + text = str(message or "") + known_codes = ( + "FRAGMENT_JSON_INVALID", "FRAGMENT_OBJECT_REQUIRED", "BATCH_GOAL_REQUIRED", + "BATCH_RELATIONSHIP_REQUIRED", "OPERATION_CONTRACT_REQUIRED", + "OPERATION_CONTRACT_MISMATCH", "CDSL_SCHEMA_INVALID", "CDSL_CANONICAL_FORMAT_REQUIRED", + "CDSL_PROFILE_INVALID", "CDSL_PROFILE_MULTIPLE_OUTERS", "CDSL_PROFILE_INNER_WITHOUT_OUTER", "CDSL_PROFILE_SELF_INTERSECTING", "OPERATION_PARAM_INVALID", "OPERATION_PARAM_UNSUPPORTED", + "Unsupported runtime atomic_id:", "UNSUPPORTED_OPERATION", + "TOPOLOGY_SNAPSHOT_OBSERVATION_REQUIRED", "TOPOLOGY_TOKEN_UNOBSERVED", + "TOPOLOGY_TOKEN_INVALID", "EDGE_SELECTOR_RECOVERY_TOKEN_REQUIRED", + "CANDIDATE_GEOMETRY_UNCHANGED", "CANDIDATE_FRAGMENT_DUPLICATE", + "CANDIDATE_ATTEMPT_LIMIT", "CANDIDATE_REVIEW_FAILED", + "GLOBAL_INVARIANT_VIOLATION", "SOLID_COUNT_MISMATCH", "DIMENSION_OUT_OF_TOLERANCE", + "REVIEW_SERVICE_ERROR", "REVIEW_RESPONSE_INVALID", "REVIEW_RESPONSE_INCOMPLETE", + "ROLLBACK_TARGET_INVALID", "ROLLBACK_TARGET_CURRENT", "ROLLBACK_WOULD_DISCARD_VALID_PROGRESS", "BEST_CHECKPOINT_REQUIRED", + "STALE_EVIDENCE", "EVIDENCE_REVISION_MISMATCH", "TOPOLOGY_TOKEN_STALE", + "NO_PROGRESS_LIMIT", "REPEATED_CDSL_ATTEMPT", "STEP_ATTEMPT_LIMIT", "COMPLETION_ALREADY_REACHED", + "PLAN_REQUIRED", "PLAN_DISABLED", "PLAN_OBJECT_INVALID", "PLAN_REFERENCE_INVALID", + "PLAN_OPERATION_UNSUPPORTED", "PLAN_DEPENDENCY_CYCLE", "PLAN_COVERAGE_INCOMPLETE", + "PLAN_REVIEW_FAILED", "PLAN_REVIEW_REVISE_LIMIT", "PLAN_STEP_REQUIRED", + "PLAN_STEP_MISMATCH", "PLAN_FEATURE_UNPLANNED", "PLAN_PREREQUISITE_UNSATISFIED", "PLAN_INCOMPLETE", + "PLAN_ACTION_REQUIRED", "PLAN_ACTION_MISMATCH", "PLAN_ACTION_ALREADY_COMPLETE", "PLAN_ACTION_UNPLANNED", + "PLAN_ACTION_PREREQUISITE_UNSATISFIED", "PLAN_ACTION_EXECUTION_SHAPE_INVALID", + "PLAN_ACTIONS_REQUIRED", "PLAN_ACTION_GRANULARITY_INVALID", + "PLAN_SKIP_UNAVAILABLE", "PLAN_SKIP_INVALID", + "GEOMETRY_CONCLUSION_NOT_REQUIRED", "GEOMETRY_CONCLUSION_EVIDENCE_REQUIRED", + "GEOMETRY_CONCLUSION_EVIDENCE_DUPLICATE", "GEOMETRY_CONCLUSION_EVIDENCE_UNKNOWN", + "GEOMETRY_CONCLUSION_PLAN_REQUIRED", "GEOMETRY_CONCLUSION_COMPLETE_BLOCKED", + ) + code = next((item for item in known_codes if text.startswith(item)), None) + if text.startswith("Unsupported runtime atomic_id:"): + code = "UNSUPPORTED_OPERATION" + if code is None and _is_engine_geometry_failure(text): + code = "ENGINE_GEOMETRY_INVALID" + if code is None: + code = "CDSL_AUTHORING_ERROR" + stage = "planning" if code.startswith("PLAN_") else ("geometry_diagnosis" if code.startswith("GEOMETRY_CONCLUSION_") else ("candidate_review" if code.startswith(("GLOBAL_", "SOLID_", "DIMENSION_", "REVIEW_")) or code == "CANDIDATE_REVIEW_FAILED" else ("candidate_build" if code.startswith("CANDIDATE_") or code == "ENGINE_GEOMETRY_INVALID" else "fragment_validation"))) + path = "fragment" + if code == "BATCH_GOAL_REQUIRED": + path = "batch_goal" + elif code == "BATCH_RELATIONSHIP_REQUIRED": + path = "batch_relationship" + elif code in {"OPERATION_CONTRACT_REQUIRED", "OPERATION_CONTRACT_MISMATCH"}: + path = "fragment.feature.atomic_id" + elif code in {"CDSL_SCHEMA_INVALID", "CDSL_CANONICAL_FORMAT_REQUIRED", "CDSL_PROFILE_INVALID", "CDSL_PROFILE_MULTIPLE_OUTERS", "CDSL_PROFILE_INNER_WITHOUT_OUTER", "CDSL_PROFILE_SELF_INTERSECTING", "OPERATION_PARAM_INVALID", "OPERATION_PARAM_UNSUPPORTED"}: + match = re.search(r"(?:at )?(fragment(?:\.[A-Za-z0-9_\[\].]+)?)", text) + path = match.group(1) if match else "fragment" + elif code.startswith("TOPOLOGY_") or code == "EDGE_SELECTOR_RECOVERY_TOKEN_REQUIRED": + path = "fragment.selector_tokens" + elif code.startswith("PLAN_ACTION"): + path = "plan_action_id" + elif code.startswith("PLAN_STEP"): + path = "plan_step_id" + elif code.startswith("PLAN_"): + path = "modeling_plan" + elif code.startswith("GEOMETRY_CONCLUSION_EVIDENCE"): + path = "evidence_refs" + elif code.startswith("GEOMETRY_CONCLUSION_"): + path = "decision" + evidence: dict[str, Any] = {} + if isinstance(arguments, dict): + value = arguments.get("fragment") + if isinstance(value, dict): + evidence["feature_count"] = _fragment_feature_count(value) + evidence["atomic_ids"] = _fragment_atomic_ids(value) + elif isinstance(arguments.get("fragment_json"), str): + evidence["legacy_fragment_json"] = True + return { + "schema_version": "cad.cdsl-diagnostic.v1", + "code": code, + "stage": stage, + "path": path, + "message": text, + "evidence": evidence, + } + + def _is_author_transport_error(error: Exception) -> bool: """Identify a retryable author-provider outage, never a CAD failure.""" return str(error).lower().startswith("llm connection failed after") @@ -172,12 +313,399 @@ def parse_completion_audit(markdown: str, checklist: list[str]) -> list[dict[str return [found[_checklist_key(item)] for item in checklist] +_PLAN_MARKER = re.compile(r"^\s*\[(STRUCTURE|FEATURE|STEP|ACTION)\s+([A-Za-z0-9][A-Za-z0-9_.-]*)\]\s*$", re.IGNORECASE) +_PLAN_KEY = re.compile(r"^\s*([a-z_][a-z0-9_]*)\s*:\s*(.*?)\s*$", re.IGNORECASE) + + +def _plan_value_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + text = str(value or "").strip() + if not text or text.casefold() in {"none", "null", "-"}: + return [] + # This helper is only for optional legacy metadata. Accept common prose + # punctuation and JSON-like arrays without making any of them mandatory. + if text.startswith("[") and text.endswith("]"): + try: + parsed = json.loads(text) + except (TypeError, json.JSONDecodeError): + parsed = None + if isinstance(parsed, list): + return [str(item).strip() for item in parsed if str(item).strip()] + text = text[1:-1] + return [item.strip().strip('"\'') for item in re.split(r"[,;,;]", text) if item.strip().strip('"\'')] + + +def _plan_bool(value: Any, *, field: str) -> bool: + text = str(value or "").strip().casefold() + if text in {"yes", "true", "1"}: + return True + if text in {"no", "false", "0"}: + return False + raise AutonomousGenerationError(f"PLAN_OBJECT_INVALID: {field} must be yes or no") + + +def _looks_like_operation_id(value: str) -> bool: + """Recognize capability-shaped names without treating ordinary prose as IDs.""" + token = str(value or "").strip().casefold() + if not token or "_" not in token: + return token in {"fillet", "chamfer"} + prefixes = ("extrude_", "revolve_", "hole_", "pattern_", "sphere_", "reference_", "cylinder_", "sweep_") + suffixes = ("_add", "_cut", "_blind", "_wizard", "_linear", "_circular", "_mirror") + return token.startswith(prefixes) or token.endswith(suffixes) + + +def _public_modeling_plan_review(review: dict[str, Any]) -> dict[str, Any]: + """Remove misleading unsupported operation names before author exposure. + + The raw reviewer response remains persisted for audit/debugging. The + author only needs the actionable fact that the plan must be revised; a + copied unsupported identifier would otherwise become the next tool call. + """ + public = deepcopy(review) + unsupported = [str(item) for item in public.pop("unsupported_operations", []) if str(item)] + if not unsupported: + return public + + def scrub(value: Any) -> Any: + if isinstance(value, str): + result = value + for name in unsupported: + result = re.sub(rf"(? dict[str, Any]: + """Keep the modeling plan as semantic context, not as a second DSL. + + The plan is deliberately tolerant: the author may write ordinary Markdown + or prose and may optionally add ``## STEP ...`` headings. Legacy + ``[STRUCTURE]``/``[FEATURE]``/``[STEP]`` records are extracted when they + are easy to recognise, but malformed fields are retained as text instead + of blocking the CAD loop. Runtime validation remains the authority for + actual CDSL and geometry. + """ + text = str(markdown or "").strip() + if not text: + raise AutonomousGenerationError("PLAN_OBJECT_INVALID: plan_text must not be empty") + # Extract optional legacy records without imposing their field grammar. + records: dict[str, list[dict[str, Any]]] = {"structure": [], "feature": [], "step": [], "action": []} + current: dict[str, Any] | None = None + pending_key = "" + for raw_line in text.splitlines(): + line = raw_line.strip() + marker = _PLAN_MARKER.match(line) + if marker: + kind, identifier = marker.groups() + normalized = kind.casefold() + current = {"id": identifier, "_kind": normalized} + records[normalized].append(current) + pending_key = "" + continue + if current is None: + continue + list_match = re.match(r"^-\s+(.+?)\s*$", line) + if list_match and pending_key: + current.setdefault(pending_key, []).append(list_match.group(1).strip()) + continue + key_match = _PLAN_KEY.match(line) + if key_match: + key, value = key_match.groups() + current[key] = value.strip() if value.strip() else [] + pending_key = key if not value.strip() else "" + structures = [] + for record in records["structure"]: + structure_id = str(record.get("id") or "").strip() + if not structure_id: + continue + structures.append({ + "structure_id": structure_id, + "role": str(record.get("role") or record.get("name") or record.get("purpose") or "").strip(), + "requirements": _plan_value_list(record.get("requirements") or record.get("checklist_coverage")), + "depends_on": _plan_value_list(record.get("depends_on")), + }) + features = [] + for record in records["feature"]: + feature_id = str(record.get("id") or "").strip() + if not feature_id: + continue + operation = str(record.get("operation") or "").strip() + # An explicitly declared feature operation is the one place where a + # semantic plan is allowed to name a machine capability. Keep prose + # tolerant, but reject a CAD-looking identifier that is not registered + # by the installed Runtime before it can reach the author context. + if runtime_atomic_ids and operation: + operation_token = re.match(r"^\s*([A-Za-z][A-Za-z0-9_]*)\b", operation) + candidate = operation_token.group(1) if operation_token else "" + if ( + candidate + and candidate != "sketch_profile" + and candidate not in runtime_atomic_ids + and _looks_like_operation_id(candidate) + ): + supported = ", ".join(sorted(runtime_atomic_ids)) + raise AutonomousGenerationError( + f"PLAN_OPERATION_UNSUPPORTED: feature {feature_id} references unsupported operation " + f"{candidate}; use semantic intent or one of: {supported}" + ) + features.append({ + "feature_id": feature_id, + "structure_id": str(record.get("structure") or "").strip(), + "operation": operation, + "purpose": str(record.get("purpose") or "").strip(), + "depends_on": _plan_value_list(record.get("depends_on")), + "topology_sensitive": str(record.get("topology_sensitive") or "").strip().casefold() in {"yes", "true", "1"}, + "evidence": _plan_value_list(record.get("evidence") or record.get("evidence_needed")), + }) + # Also catch the same explicit labels when the author uses ordinary + # Markdown rather than the legacy [FEATURE] records. Free-form prose is + # still accepted; only operation:/atomic_id: declarations are checked. + if runtime_atomic_ids: + declared_operations = re.findall( + r"(?im)^\s*(?:[-*]\s*)?(?:operation|atomic_id)\s*[:=]\s*([A-Za-z][A-Za-z0-9_]*)\b", + text, + ) + for candidate in declared_operations: + if candidate == "sketch_profile" or candidate in runtime_atomic_ids or not _looks_like_operation_id(candidate): + continue + supported = ", ".join(sorted(runtime_atomic_ids)) + raise AutonomousGenerationError( + f"PLAN_OPERATION_UNSUPPORTED: declared operation {candidate} is not supported by Runtime; " + f"use semantic intent or one of: {supported}" + ) + known_step_ids: set[str] = set() + saw_step_heading = False + steps = [] + legacy_steps = records["step"] + if legacy_steps: + for record in legacy_steps: + step_id = str(record.get("id") or "").strip() + if step_id and step_id not in known_step_ids: + known_step_ids.add(step_id) + steps.append({ + "step_id": step_id, + "title": str(record.get("goal") or "").strip(), + "goal": str(record.get("goal") or "").strip(), + "feature_ids": [item for item in _plan_value_list(record.get("features")) if item in {f["feature_id"] for f in features}], + "covers": [], "relationship": str(record.get("relationship") or "").strip(), + "prerequisites": _plan_value_list(record.get("prerequisites")), "expected_state_change": str(record.get("expected_state_change") or "").strip(), + "evidence": _plan_value_list(record.get("evidence") or record.get("evidence_needed")), "status": "pending", + }) + else: + # Optional Markdown step headings. Any prose before the first heading + # stays with the first step so no author content is discarded. + lines = text.splitlines() + heading = re.compile(r"^\s{0,3}#{1,6}\s*(?:(?:step|步骤)\s*)?(?:第\s*)?(\d+)?\s*(?:步)?\s*[:.-]?\s*(.*?)\s*$", re.IGNORECASE) + sections: list[tuple[str, list[str]]] = [] + saw_step_heading = False + current_title = "整体建模计划" + current_lines: list[str] = [] + for line in lines: + match = heading.match(line) + if match and (match.group(1) or re.search(r"\bstep\b|步骤|第\s*\d+\s*步", line, re.IGNORECASE)): + saw_step_heading = True + if current_lines or not sections: + sections.append((current_title, current_lines)) + current_title = (match.group(2) or f"步骤 {len(sections) + 1}").strip() + current_lines = [] + else: + current_lines.append(line) + sections.append((current_title, current_lines)) + sections = [(title, body) for title, body in sections if "\n".join(body).strip() or len(sections) == 1] + for index, (title, body) in enumerate(sections, 1): + step_id = f"step_{index}" + known_step_ids.add(step_id) + body_text = "\n".join(body).strip() + steps.append({ + "step_id": step_id, "title": title or f"步骤 {index}", "goal": title or f"步骤 {index}", + "feature_ids": [], "covers": [], "relationship": "", "prerequisites": [], + "expected_state_change": body_text[:500], "body": body_text, "evidence": [], "status": "pending", + }) + if not saw_step_heading and len(steps) == 1: + steps[0]["step_id"] = "semantic_plan" + mode = "indexed" if saw_step_heading or len(steps) > 1 or legacy_steps or records["structure"] or records["feature"] or records["action"] else "semantic_only" + if not steps: + steps = [{"step_id": "semantic_plan", "title": "整体建模计划", "goal": "整体建模计划", "feature_ids": [], "covers": [], "relationship": "", "prerequisites": [], "expected_state_change": text[:500], "body": text, "evidence": [], "status": "pending"}] + # A semantic step may contain several related actions (for example the + # several related targets). Each action is still submitted as one + # independent fragment. Explicit ACTION records are preferred; when a + # legacy feature graph is present, features become one action each; pure + # prose receives one compatible default action per step. + feature_by_id = {str(item.get("feature_id") or ""): item for item in features if isinstance(item, dict)} + actions: list[dict[str, Any]] = [] + explicit_actions = records.get("action") or [] + seen_action_ids: set[str] = set() + for record in explicit_actions: + action_id = str(record.get("id") or "").strip() + if not action_id: + continue + if action_id in seen_action_ids: + raise AutonomousGenerationError(f"PLAN_REFERENCE_INVALID: duplicate action id {action_id}") + seen_action_ids.add(action_id) + step_id = str(record.get("step") or record.get("step_id") or "").strip() + operation = str(record.get("operation") or record.get("atomic_id") or "").strip() + if runtime_atomic_ids and operation: + candidate = re.match(r"^\s*([A-Za-z][A-Za-z0-9_]*)\b", operation) + token = candidate.group(1) if candidate else "" + if token and token != "sketch_profile" and token not in runtime_atomic_ids and _looks_like_operation_id(token): + supported = ", ".join(sorted(runtime_atomic_ids)) + raise AutonomousGenerationError( + f"PLAN_OPERATION_UNSUPPORTED: action {action_id} references unsupported operation {token}; " + f"use semantic intent or one of: {supported}" + ) + required_raw = str(record.get("required") or "true").strip().casefold() + required = required_raw not in {"false", "no", "0", "optional"} + action = { + "action_id": action_id, + "step_id": step_id, + "title": str(record.get("title") or record.get("goal") or record.get("purpose") or action_id).strip(), + "target": str(record.get("target") or "").strip(), + "operation": operation, + "required": required, + "depends_on": _plan_value_list(record.get("depends_on") or record.get("prerequisites")), + "feature_id": str(record.get("feature_id") or "").strip(), + "relationship": str(record.get("relationship") or "").strip(), + "source": "explicit", + "status": "pending", + } + actions.append(action) + step_ids = {str(item.get("step_id") or "") for item in steps if isinstance(item, dict)} + for action in actions: + if action["step_id"] not in step_ids: + if action["step_id"]: + raise AutonomousGenerationError( + f"PLAN_REFERENCE_INVALID: action {action.get('action_id')} references unknown step {action['step_id']}" + ) + action["step_id"] = "semantic_plan" if "semantic_plan" in step_ids else (next(iter(step_ids), "")) + if not actions: + for step in steps: + step_id = str(step.get("step_id") or "") + feature_ids = [str(value) for value in step.get("feature_ids") or () if str(value)] + if feature_ids: + for feature_id in feature_ids: + feature = feature_by_id.get(feature_id) or {} + actions.append({ + "action_id": feature_id, + "step_id": step_id, + "title": str(feature.get("purpose") or feature_id), + "target": str(feature.get("structure_id") or ""), + "operation": str(feature.get("operation") or ""), + "required": True, + "depends_on": list(feature.get("depends_on") or []), + "feature_id": feature_id, + "relationship": str(step.get("relationship") or ""), + "source": "feature", + "status": "pending", + }) + else: + body = str(step.get("body") or step.get("expected_state_change") or "") + soft_actions = re.findall(r"(?im)^\s{0,6}#{1,6}\s*(?:action|动作)\s*(?:\d+[a-z]?\s*[-:.)]?\s*)?(.*?)\s*$", body) + soft_actions = [item.strip() for item in soft_actions if item.strip()] + if not soft_actions: + soft_actions = [item.strip() for item in re.findall(r"(?im)^\s*[-*]\s*(?:action|动作)\s*[::]\s*(.+?)\s*$", body) if item.strip()] + titles = soft_actions or [str(step.get("goal") or step.get("title") or step_id)] + for action_index, title in enumerate(titles, 1): + actions.append({ + "action_id": f"{step_id}_action_{action_index}", + "step_id": step_id, + "title": title, + "target": "", + "operation": "", + "required": True, + "depends_on": [f"{step_id}_action_{action_index - 1}"] if action_index > 1 else [], + "feature_id": "", + "relationship": str(step.get("relationship") or ""), + "source": "heading" if soft_actions else "default", + "status": "pending", + }) + else: + # Mixed plans may explicitly describe actions for only some steps. + # Keep every step executable by adding a prose-compatible default to + # the remaining steps; this does not infer geometry. + covered_steps = {str(item.get("step_id") or "") for item in actions} + for step in steps: + step_id = str(step.get("step_id") or "") + if step_id in covered_steps: + continue + actions.append({ + "action_id": f"{step_id}_action_1", + "step_id": step_id, + "title": str(step.get("goal") or step.get("title") or step_id), + "target": "", + "operation": "", + "required": True, + "depends_on": [], + "feature_id": "", + "relationship": str(step.get("relationship") or ""), + "source": "default", + "status": "pending", + }) + actions_by_step: dict[str, list[dict[str, Any]]] = {} + unique_action_ids: set[str] = set() + for action in actions: + action_id = str(action.get("action_id") or "") + if action_id and action_id in unique_action_ids: + raise AutonomousGenerationError(f"PLAN_REFERENCE_INVALID: duplicate action id {action_id}") + if action_id: + unique_action_ids.add(action_id) + actions_by_step.setdefault(str(action.get("step_id") or ""), []).append(action) + action_ids = {str(item.get("action_id") or "") for item in actions if str(item.get("action_id") or "")} + for action in actions: + unknown = [item for item in action.get("depends_on") or () if str(item) not in action_ids] + if unknown: + raise AutonomousGenerationError( + f"PLAN_REFERENCE_INVALID: action {action.get('action_id')} depends on unknown action(s): {', '.join(unknown)}" + ) + visiting: set[str] = set() + visited: set[str] = set() + by_action_id = {str(item.get("action_id") or ""): item for item in actions} + def visit(action_id: str) -> None: + if action_id in visiting: + raise AutonomousGenerationError(f"PLAN_DEPENDENCY_CYCLE: action dependency cycle includes {action_id}") + if action_id in visited: + return + visiting.add(action_id) + for dependency in by_action_id[action_id].get("depends_on") or (): + visit(str(dependency)) + visiting.remove(action_id) + visited.add(action_id) + for action_id in action_ids: + visit(action_id) + for step in steps: + step["actions"] = deepcopy(actions_by_step.get(str(step.get("step_id") or ""), [])) + step["action_ids"] = [str(item.get("action_id") or "") for item in step["actions"] if str(item.get("action_id") or "")] + return { + "schema_version": "cad.modeling-plan.v1", + "plan_version": 1, + "mode": mode, + "plan_text": text, + "structures": structures, + "features": features, + "actions": actions, + "steps": steps, + } + + def _format_correction_card( engine: Any, *, - fragment_json: str, + fragment_json: str = "", + fragment: dict[str, Any] | None = None, error_message: str, head: str, + shared_revolve_axis: dict[str, Any] | None = None, previous: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build an exact, runtime-derived correction instruction for the author. @@ -188,15 +716,19 @@ def _format_correction_card( not a geometry template: it exposes only the contract of the feature the author attempted and leaves the workplane, profile and dimensions to it. """ - raw = str(fragment_json or "") + raw = ( + json.dumps(fragment, ensure_ascii=True, sort_keys=True) + if isinstance(fragment, dict) + else str(fragment_json or "") + ) atomic_id = "" attempted_params: dict[str, Any] = {} attempted_feature_count = 0 try: - fragment = json.loads(raw) - feature = fragment.get("feature") if isinstance(fragment, dict) else None - if not isinstance(feature, dict) and isinstance(fragment, dict): - features = fragment.get("features") + parsed_fragment: Any = fragment if isinstance(fragment, dict) else json.loads(raw) + feature = parsed_fragment.get("feature") if isinstance(parsed_fragment, dict) else None + if not isinstance(feature, dict) and isinstance(parsed_fragment, dict): + features = parsed_fragment.get("features") attempted_feature_count = len(features) if isinstance(features, list) else 0 feature = features[0] if isinstance(features, list) and features and isinstance(features[0], dict) else None elif isinstance(feature, dict): @@ -252,6 +784,21 @@ def _format_correction_card( if key not in {*required, *optional} ), }) + if atomic_id.startswith("revolve_") and attempted_params.get("angle_deg") is None: + shared_axis_note = ( + "shared_revolve_axis was supplied and fills only params.axis; it never supplies params.angle_deg." + if isinstance(shared_revolve_axis, dict) + else "A batch revolve_axis fills only params.axis; it never supplies params.angle_deg." + ) + card.update({ + "missing_author_params": ["angle_deg"], + "shared_revolve_axis_note": shared_axis_note, + "instruction": ( + f"{atomic_id} requires params.angle_deg. Add an explicit positive degree value (360 for a full revolution) " + "to this replacement fragment. Do not infer or omit it; partial revolutions are valid. " + + shared_axis_note + ), + }) selector_error = "selector" in error_message.lower() if selector_error and operation["selector_rule"]: # Finish operations are selector-only. Showing their parameter @@ -265,6 +812,62 @@ def _format_correction_card( valid_shape = card.get("valid_feature_shape") if isinstance(valid_shape, dict): valid_shape["selector_tokens"] = [f"1..64 opaque {selector_kind} token(s) returned by inspect_topology"] + if atomic_id.startswith("hole_") or atomic_id == "hole_wizard": + # Holes have a server-injected face and a strict position object + # shape. Preserve every reported issue together so one retry can + # correct the token, required fields, and unsupported aliases. + issue_text = error_message.partition(":")[2] if error_message.startswith("HOLE_FRAGMENT_INVALID:") else error_message + issues = [item.strip() for item in issue_text.split(";") if item.strip()] + author_allowed = set(author_required) | (set(optional) - set(operation["server_injected_params"])) + card.update({ + "issues": issues, + "server_owned_attempted_params": sorted( + key for key in attempted_params if key in operation["server_injected_params"] + ), + "unsupported_attempted_params": sorted(key for key in attempted_params if key not in author_allowed), + "minimal_params_shape": { + "diameter_mm": "positive number", + "depth_mm": "positive number", + "positions": [{"mm": "[x_mm, y_mm, z_mm]"}], + }, + "valid_feature_shape": { + "atomic_id": atomic_id, + "params": { + "diameter_mm": "positive number", + "depth_mm": "positive number", + "positions": [{"mm": "[x_mm, y_mm, z_mm]"}], + }, + "selector_tokens": ["exactly one current face token"], + }, + "selector_tokens_required": True, + "selector_token_kind": "face", + "instruction": ( + "Replace the hole fragment with exactly one current face token and only the listed hole params. " + "Use positions as [{\"mm\":[x_mm,y_mm,z_mm]}]; host_face is injected by the server. " + "Correct every item in issues in the same replacement." + ), + }) + if "analytic_contours" in raw and "not valid under any of the given schemas" in error_message: + card.update({ + "profile_correction": { + "valid_profile_shape": { + "type": "analytic_contours", + "contours": [{ + "role": "outer or inner", + "closed": True, + "segments": [{"type": "circle", "center": "[u_mm, v_mm]", "radius_mm": "positive number"}], + }], + }, + "rules": [ + "Each contour needs role, closed, and segments; a contour cannot be a bare {type: circle, center, radius_mm} object.", + "Use one outer contour per separate cut island. Use an outer plus inner contour only for a concentric annulus.", + ], + }, + "instruction": ( + "Correct the analytic_contours profile using profile_correction.valid_profile_shape. " + "Do not submit bare circle objects in contours." + ), + }) match = re.search(r"A fragment may add at most (\d+) feature\(s\)", error_message) if match: allowed = int(match.group(1)) @@ -287,14 +890,107 @@ def _requires_edge_selector_recovery(correction: dict[str, Any]) -> bool: ) +def _is_engine_geometry_failure(message: str) -> bool: + """Recognize runtime BRep/export failures that are not authoring syntax.""" + lowered = str(message or "").lower() + return any(marker in lowered for marker in ( + "step tessellation produced no renderable triangles", + "nbnodes", + "brep_api: command not done", + "command not done", + "boolean operation failed", + "boolean operation could not", + "fillet_radius_unavailable", + "fillet geometry invalid", + "chamfer geometry invalid", + "brep geometry failure", + "engine returned an invalid model bounding box", + "engine returned a non-positive model volume", + "engine returned no valid solid body", + "engine did not produce a valid step artifact", + "step preview conversion did not produce a valid glb artifact", + "topods::", + "topods_", + "brep", + "brepbuilderapi", + "cannot build face", + "invalid face", + "invalid wire", + "self-intersection", + "self intersection", + "non-manifold", + )) + + def _fragment_selector_tokens(fragment: dict[str, Any]) -> list[str]: """Read a fragment's authored selector tokens without changing it.""" - feature = fragment.get("feature") if isinstance(fragment.get("feature"), dict) else None - if feature is None: - features = fragment.get("features") - feature = features[0] if isinstance(features, list) and features and isinstance(features[0], dict) else None - raw = (feature or {}).get("selector_tokens", fragment.get("selector_tokens", [])) - return [item for item in raw if isinstance(item, str)] if isinstance(raw, list) else [] + values: list[str] = [] + + def append_tokens(raw: Any) -> None: + if isinstance(raw, list): + values.extend(item for item in raw if isinstance(item, str)) + + append_tokens(fragment.get("selector_tokens")) + feature = fragment.get("feature") + if isinstance(feature, dict): + append_tokens(feature.get("selector_tokens")) + features = fragment.get("features", fragment.get("add_features")) + if isinstance(features, list): + for item in features: + if not isinstance(item, dict): + continue + nested = item.get("feature") + append_tokens(nested.get("selector_tokens") if isinstance(nested, dict) else item.get("selector_tokens")) + return list(dict.fromkeys(values)) + + +def _fragment_atomic_ids(fragment: dict[str, Any]) -> list[str]: + """Return authored feature operations in fragment order without rewriting it.""" + candidates: list[dict[str, Any]] = [] + feature = fragment.get("feature") + if isinstance(feature, dict): + candidates.append(feature) + features = fragment.get("features", fragment.get("add_features")) + if isinstance(features, list): + for item in features: + if not isinstance(item, dict): + continue + nested = item.get("feature") + candidates.append(nested if isinstance(nested, dict) else item) + result: list[str] = [] + for feature in candidates: + atomic_id = str(feature.get("atomic_id") or "").strip() + if atomic_id and atomic_id not in result: + result.append(atomic_id) + return result + + +def _fragment_feature_count(fragment: dict[str, Any]) -> int: + """Count authored features without deduplicating repeated atomic IDs.""" + feature = fragment.get("feature") + if isinstance(feature, dict): + return 1 + features = fragment.get("features", fragment.get("add_features")) + return len(features) if isinstance(features, list) else 0 + + +def _unread_operation_contract_ids( + state: dict[str, Any], + fragment: dict[str, Any], + base_cdsl: dict[str, Any] | None, +) -> list[str]: + """Require a contract lookup before a task first authors a new operation.""" + read_ids = { + str(item) + for item in state.get("read_operation_contract_ids") or () + if isinstance(item, str) and str(item) + } + existing_ids = { + str(feature.get("atomic_id") or "") + for feature in ((base_cdsl or {}).get("features") or ()) + if isinstance(feature, dict) and str(feature.get("atomic_id") or "") + } + return [atomic_id for atomic_id in _fragment_atomic_ids(fragment) if atomic_id not in existing_ids and atomic_id not in read_ids] def _final_repair_card(task: dict[str, Any], review: dict[str, Any]) -> dict[str, Any]: @@ -396,6 +1092,19 @@ def autonomous_tools() -> list[dict[str, Any]]: "parameters": {"type": "object", "properties": {"markdown": {"type": "string", "minLength": 1}}, "required": ["markdown"], "additionalProperties": False}, }, }, + { + "type": "function", + "function": { + "name": "write_modeling_plan", + "description": "Write a semantic modeling plan after completion.md and before CAD observation or CDSL. Use ordinary Markdown or prose with ## Step headings. When one step contains independently located profiles or Runtime features, keep the related targets in that step but add one ### Action heading per target. One action must be executable as one Runtime feature. A supported multi-position hole or pattern may remain one action when it shares one host and operation contract. No JSON is required. This is context for another LLM, not CDSL.", + "parameters": { + "type": "object", + "properties": {"plan_text": {"type": "string", "minLength": 40, "maxLength": 16000}}, + "required": ["plan_text"], + "additionalProperties": False, + }, + }, + }, {"type": "function", "function": {"name": "inspect_model", "description": "Inspect the active checkpoint's compact geometry, feature and sketch summary. Use before deciding the next CDSL step.", "parameters": empty}}, { "type": "function", @@ -449,24 +1158,52 @@ def autonomous_tools() -> list[dict[str, Any]]: "type": "function", "function": { "name": "submit_cdsl_fragment", - "description": "Submit the next coherent append-only CAD batch as JSON. Include batch_goal and a fragment with 1..6 ordered feature operations using {sketches,features}; single {sketch,feature} remains accepted. For revolve batches, pass shared_revolve_axis as {origin_mm,direction} once instead of repeating axis within fragment_json. Do not include CDSL ids, dependencies, sketch_id or raw selectors. Topology-sensitive operations may use only selector_tokens from the current checkpoint.", - "parameters": {"type": "object", "properties": {"batch_goal": {"type": "string", "minLength": 8, "maxLength": 800}, "fragment_json": {"type": "string", "minLength": 2}, "shared_revolve_axis": {"type": "object", "properties": {"origin_mm": point3, "direction": point3}, "required": ["origin_mm", "direction"], "additionalProperties": False}}, "required": ["batch_goal", "fragment_json"], "additionalProperties": False}, + "description": "Submit exactly one operation for the active plan action as a structured CDSL fragment object. A semantic step may contain multiple related actions, but each action requires its own fragment and checkpoint. The active operation contract and dynamic tool schema are authoritative. Legacy aliases, feature arrays, batch_relationship, shared_revolve_axis, ids, dependencies, sketch_id, and raw selectors are invalid.", + "parameters": {"type": "object", "properties": {"plan_step_id": {"type": "string", "minLength": 1}, "plan_action_id": {"type": "string", "minLength": 1}, "batch_goal": {"type": "string", "minLength": 8, "maxLength": 800}, "batch_relationship": {"type": "string", "minLength": 12, "maxLength": 1000}, "fragment": {"type": "object", "minProperties": 1}, "shared_revolve_axis": {"type": "object", "properties": {"origin_mm": point3, "direction": point3}, "required": ["origin_mm", "direction"], "additionalProperties": False}}, "required": ["batch_goal", "fragment"], "additionalProperties": False}, }, }, { "type": "function", "function": { "name": "record_geometry_conclusion", - "description": "Required after a candidate rebuilt with unchanged geometry or a completion audit finds unresolved requirements. Cite one or more current diagnostic evidence_refs, state the root cause, then choose modify, rollback, or complete. For modify, optimization_plan must explain the next materially different modelling action. This records a decision only and never writes CDSL.", + "description": "Required after a candidate rebuilt with unchanged geometry or a completion audit finds unresolved requirements. Cite one or more current diagnostic evidence_refs, state the root cause, then choose modify, rollback, skip, or complete. Choose skip only when current evidence proves this plan step was already satisfied by an earlier feature; skip advances the plan without writing CDSL. For modify, supply optimization_plan.action with the next materially different modelling action, for example {\"optimization_plan\":{\"action\":\"move the hole cut to the observed top face\"}}. next_action is accepted as a legacy spelling. This records a decision only and never writes CDSL.", "parameters": { "type": "object", "properties": { "root_cause": {"type": "string", "enum": ["duplicate_feature", "selector_miss", "invalid_plane_or_direction", "unsupported_operation", "incomplete_requirements", "unknown"]}, "evidence_refs": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "maxItems": 3}, - "decision": {"type": "string", "enum": ["modify", "rollback", "complete"]}, - "optimization_plan": {"type": "object", "additionalProperties": True}, + "decision": {"type": "string", "enum": ["modify", "rollback", "skip", "complete"]}, + "optimization_plan": { + "type": "object", + "properties": { + "action": {"type": "string", "minLength": 1}, + "next_action": {"type": "string", "minLength": 1}, + "reason": {"type": "string", "minLength": 1}, + }, + "anyOf": [{"required": ["action"]}, {"required": ["next_action"]}, {"required": ["reason"]}], + "additionalProperties": True, + }, }, "required": ["root_cause", "evidence_refs", "decision"], + "allOf": [{ + "if": {"properties": {"decision": {"const": "modify"}}, "required": ["decision"]}, + "then": {"required": ["optimization_plan"]}, + }], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "skip_satisfied_plan_step", + "description": "Advance the active modeling-plan step without CDSL only when the server has already verified every frozen completion item at the current revision. This tool is exposed only while that proof is current.", + "parameters": { + "type": "object", + "properties": { + "reason": {"type": "string", "minLength": 1, "maxLength": 800}, + }, + "required": ["reason"], "additionalProperties": False, }, }, @@ -506,8 +1243,89 @@ def _runtime_summary(engine: Any) -> list[dict[str, Any]]: return result +def _planning_reference_context(settings: Settings, requirements: str) -> dict[str, Any]: + """Return a bounded, advisory skill/library context for plan authoring.""" + skill_root = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "references" / "part-skills" + catalog = read_json(skill_root / "catalog.json", {}) + text = str(requirements or "").casefold() + matched: list[dict[str, Any]] = [] + for item in ((catalog.get("skills") or ()) if isinstance(catalog, dict) else ()): + if not isinstance(item, dict): + continue + triggers = [str(value).casefold() for value in item.get("triggers") or () if str(value)] + excludes = [str(value).casefold() for value in item.get("exclude") or () if str(value)] + if not any(trigger in text for trigger in triggers) or any(value in text for value in excludes): + continue + matched.append(item) + matched.sort(key=lambda item: (-int(item.get("priority") or 0), str(item.get("id") or ""))) + selected: list[dict[str, Any]] = [] + planning_count = 0 + support_count = 0 + for item in matched: + kind = str(item.get("kind") or "") + if kind == "planning": + if planning_count >= 1: + continue + planning_count += 1 + else: + if support_count >= 3: + continue + support_count += 1 + bridge = skill_root / str(item.get("bridge") or "") + selected.append({ + "id": str(item.get("id") or ""), + "kind": kind, + "title": str(item.get("title") or ""), + "guidance": bridge.read_text(encoding="utf-8")[:2500] if bridge.is_file() else "", + }) + try: + samples = CdslLibrary(settings).search(requirements, limit=3) + except (OSError, ValueError, json.JSONDecodeError): + samples = [] + return { + "precedence": "Runtime operation contracts and user requirements override every skill and library example.", + "skills": selected, + "library_examples": samples, + "library_rule": "Examples are expression-pattern references only. Never copy their CDSL ids or treat them as executable requirements.", + } + + +def _operation_authoring_rule(atomic_id: str, selector_rule: dict[str, Any] | None) -> str: + """Give the author a usable fragment shape, not just a parameter list.""" + if atomic_id.startswith("revolve_"): + return ( + "Use {sketch:{workplane,profile}, feature:{atomic_id,params}}. " + "params must contain explicit angle_deg (degrees, not radians) and axis " + "{origin_mm:[x,y,z], direction:[dx,dy,dz]}. Shared axes and top-level axis fields are not accepted. " + "The axis direction must be parallel to the sketch plane and its origin must lie in that plane. " + "Never use selector_tokens for an axis." + ) + if atomic_id.startswith("extrude_"): + return ( + "Use {sketch:{workplane,profile}, feature:{atomic_id,params}}. " + "Put only distance_mm (and optional reverse) in params. Extrusion " + "direction comes from sketch.workplane.normal; do not put workplane, " + "profile, or direction in params." + ) + if atomic_id == "sphere_add": + return "Use {feature:{atomic_id:'sphere_add',params:{radius_mm,center_mm}}}; sphere_add has no sketch." + if atomic_id.startswith("hole_") or atomic_id == "hole_wizard": + return ( + "Use a sketchless feature. First inspect_topology(kind='face'), then place " + "exactly one returned face token in selector_tokens. positions uses " + "[{\"mm\":[x_mm,y_mm,z_mm]}]. The server injects host_face through " + "server_injected_params. One hole feature cannot span different host faces." + ) + if selector_rule: + return ( + "Use a sketchless feature. First inspect_topology for the required token kind, " + "then put only returned opaque tokens in selector_tokens." + ) + return "Use only the listed params. Do not add selector_tokens or a sketch unless requires_sketch is true." + + def _operation_contract_payload(engine: Any, atomic_id: str) -> dict[str, Any]: - """Return runtime documentation, never authored CAD geometry.""" + """Return runtime documentation and a schema-valid authoring template.""" contract = feature_atomic_contract(engine, atomic_id) slot = contract.get("selector_slot") if isinstance(contract.get("selector_slot"), dict) else None runtime_required = list(contract["required_params"]) @@ -540,7 +1358,7 @@ def _operation_contract_payload(engine: Any, atomic_id: str) -> dict[str, Any]: server_injected.append("source_feature_ids") author_required = [name for name in runtime_required if name not in server_injected] - return { + payload = { "atomic_id": contract["atomic_id"], "summary": contract["summary"], "requires_sketch": contract["requires_sketch"], @@ -550,18 +1368,38 @@ def _operation_contract_payload(engine: Any, atomic_id: str) -> dict[str, Any]: "server_injected_params": server_injected, "position_format": contract.get("position_format") or None, "selector_rule": selector_rule, - "authoring_rule": ( - "For every revolve feature, supply params.axis as {origin_mm:[x,y,z], direction:[dx,dy,dz]}. " - "When one batch has several revolve features with the same axis, declare that explicit author-defined " - "axis once as top-level revolve_axis and the server copies it only to those missing params.axis values. " - "Never use selector_tokens for a revolve axis." - if atomic_id.startswith("revolve_") else - "Put only opaque selector tokens returned by inspect_topology in selector_tokens. " - "The server injects server_injected_params from those tokens." - if selector_rule else - "Do not add selector_tokens for this operation." - ), + "authoring_rule": _operation_authoring_rule(atomic_id, selector_rule), } + canonical_schema = build_operation_fragment_schema(engine, atomic_id) + payload.update({ + "canonical_fragment_schema": canonical_schema, + "canonical_fragment_example": canonical_fragment_example(engine, atomic_id), + "canonical_example_note": "Example is schema-only. Replace every dimension, coordinate, and selector token with values from current requirements/topology; placeholder token strings are not executable.", + "contract_hash": operation_contract_hash(atomic_id, canonical_schema), + "canonical_only": True, + "legacy_aliases_allowed": False, + }) + if atomic_id.startswith("revolve_"): + sketch = { + "workplane": { + "origin_mm": [0, 0, 0], + "x_dir": [1, 0, 0], + "normal": [0, -1, 0], + }, + "profile": { + "type": "polygon", + "vertices": [[10, -5], [20, -5], [20, 5], [10, 5]], + }, + } + axis = {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]} + payload["fragment_template"] = { + "sketch": sketch, + "feature": { + "atomic_id": atomic_id, + "params": {"angle_deg": 360, "axis": axis}, + }, + } + return payload def _checkpoint_token(branch_id: str, revision_id: str) -> str: @@ -591,6 +1429,51 @@ def _rollback_tokens(task: dict[str, Any]) -> dict[str, str]: return tokens +def _rollback_token_details(task: dict[str, Any]) -> list[dict[str, Any]]: + """Describe rollback handles without making the current head selectable.""" + branch_id = str(task.get("active_branch_id") or "main") + revisions = { + str(item.get("revision_id") or ""): item + for item in task.get("revisions") or () + if isinstance(item, dict) + } + current = str(task.get("active_revision") or "") + details: list[dict[str, Any]] = [{ + "token": "root", + "revision_id": "", + "current": not bool(current), + "rollback_allowed": bool(current), + "recommended_for_rollback": False, + "parent_token": None, + }] + pointer = current + child = "" + while pointer: + record = revisions.get(pointer) or {} + parent = str(record.get("parent_revision_id") or "") + details.append({ + "token": _checkpoint_token(branch_id, pointer), + "revision_id": pointer, + "current": pointer == current, + "rollback_allowed": pointer != current, + "recommended_for_rollback": bool(child and pointer == parent), + "parent_token": _checkpoint_token(branch_id, parent) if parent else "root", + "quality_score": record.get("quality_score"), + "plan_step_id": record.get("plan_step_id") or "", + "plan_action_id": record.get("plan_action_id") or "", + "completed_plan_actions": list(record.get("completed_plan_actions") or []), + }) + child = pointer + pointer = parent + # The immediate parent is the least destructive default target. + if details and current: + parent_revision = str((revisions.get(current) or {}).get("parent_revision_id") or "") + for item in details: + if str(item.get("revision_id") or "") == parent_revision: + item["recommended_for_rollback"] = True + return details + + def _model_paths(store: WorkspaceStore, task_id: str, task: dict[str, Any]) -> tuple[Path | None, Path | None, Path | None, str]: """Return active candidate artifacts first, otherwise active checkpoint.""" candidate_id = str(task.get("active_candidate_id") or "") @@ -765,6 +1648,49 @@ def _candidate_health(engine_result: dict[str, Any], step_path: Path, glb_path: } +def _global_geometry_violations( + health: dict[str, Any], + *, + requirements: str, + checklist: list[str], + check_dimensions: bool = True, +) -> list[dict[str, Any]]: + """Return cheap, observable blockers that apply to every candidate. + + This deliberately does not infer CAD operations. It only protects + invariants that are already observable in the deterministic rebuild + report, so a local batch reviewer cannot accept a globally invalid model. + """ + violations: list[dict[str, Any]] = [] + solid_count = health.get("solid_count") + requires_single = any( + marker in (requirements + "\n" + "\n".join(checklist)) + for marker in ("单个机械零件", "单一实体", "所有主要实体相互连接", "one coherent solid", "single connected") + ) + if requires_single and isinstance(solid_count, (int, float)) and int(solid_count) != 1: + violations.append({ + "code": "SOLID_COUNT_MISMATCH", + "message": f"要求单一连通实体,但当前 solid_count={int(solid_count)}", + "evidence": {"solid_count": int(solid_count)}, + }) + dimensions = ((health.get("bbox_mm") or {}).get("dimensions") if isinstance(health.get("bbox_mm"), dict) else None) or [] + # Frozen image-derived requirements commonly state the expected total + # height as "总高度约 82 mm". Apply a conservative absolute tolerance; + # absent that explicit assumption, do not invent a dimension constraint. + match = re.search(r"总高度[^0-9]{0,16}(\d+(?:\.\d+)?)[^0-9]{0,8}mm", requirements or "", re.IGNORECASE) if check_dimensions else None + if match and len(dimensions) >= 3: + expected = float(match.group(1)) + actual = float(dimensions[2]) + tolerance = max(2.0, expected * 0.05) + if abs(actual - expected) > tolerance: + violations.append({ + "code": "DIMENSION_OUT_OF_TOLERANCE", + "message": f"模型总高度 {actual:.3f} mm 超出冻结值 {expected:.3f} mm ± {tolerance:.3f} mm", + "evidence": {"axis": "Z", "actual_mm": actual, "expected_mm": expected, "tolerance_mm": tolerance}, + }) + return violations + + def build_candidate( *, settings: Settings, @@ -819,6 +1745,8 @@ def build_candidate( "feature_ids": fragment_audit.get("assigned_feature_ids") or [], "sketch_ids": fragment_audit.get("assigned_sketch_ids") or [], "compatibility_fixes": fragment_audit.get("compatibility_fixes") or [], + "compatibility_fix_count": int(fragment_audit.get("compatibility_fix_count") or 0), + "legacy_input": bool(fragment_audit.get("legacy_input")), } write_json(candidate_dir / "candidate.json", candidate) return candidate @@ -888,6 +1816,14 @@ def commit_candidate( "visibility": "checkpoint", "node_id": "", "candidate_id": candidate_id, + "plan_step_id": str(candidate.get("plan_step_id") or ""), + "plan_step_goal": str(candidate.get("plan_step_goal") or ""), + "plan_action_id": str(candidate.get("plan_action_id") or ""), + "plan_action_title": str(candidate.get("plan_action_title") or ""), + "completed_plan_actions": list(candidate.get("completed_plan_actions") or []), + "atomic_ids": candidate.get("atomic_ids") or [], + "legacy_input": bool(candidate.get("legacy_input")), + "compatibility_fix_count": int(candidate.get("compatibility_fix_count") or 0), # Retain the legacy field for old task readers while new clients use # candidate_review_path to distinguish an independent verdict. "step_review_path": relative(review_relative.as_posix()), @@ -899,12 +1835,76 @@ def commit_candidate( class AutonomousCdslGenerationRunner: - def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion) -> None: + def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion, on_text_delta: Callable[[str], Awaitable[None]] | None = None) -> None: self.settings = settings self.store = store self.complete = complete + self.on_text_delta = on_text_delta self.tools = autonomous_tools() + @staticmethod + def _clear_active_operation_contract(state: dict[str, Any]) -> None: + state["active_operation_contract"] = {} + state["pending_operation"] = "" + state["pending_operation_contract_hash"] = "" + state["pending_operation_revision"] = "" + + def _active_contract(self, task: dict[str, Any], state: dict[str, Any] | None, engine: Any | None = None) -> dict[str, Any] | None: + contract = (state or {}).get("active_operation_contract") + if not isinstance(contract, dict) or not str(contract.get("atomic_id") or ""): + return None + if str((state or {}).get("pending_operation_revision") or "") != str(task.get("active_revision") or ""): + return None + schema = contract.get("canonical_fragment_schema") + atomic_id = str(contract.get("atomic_id") or "") + if not isinstance(schema, dict): + return None + expected_hash = operation_contract_hash(atomic_id, schema) + if str((state or {}).get("pending_operation_contract_hash") or "") != expected_hash: + return None + if engine is not None: + try: + runtime_schema = build_operation_fragment_schema(engine, atomic_id) + except (ValueError, KeyError): + return None + if operation_contract_hash(atomic_id, runtime_schema) != expected_hash: + return None + return contract + + def _canonical_submit_tool(self, task: dict[str, Any], state: dict[str, Any] | None) -> dict[str, Any] | None: + contract = self._active_contract(task, state) + if contract is None: + return None + base = next( + tool for tool in self.tools + if str((tool.get("function") or {}).get("name") or "") == "submit_cdsl_fragment" + ) + tool = deepcopy(base) + function = tool["function"] + fragment_schema = deepcopy(contract["canonical_fragment_schema"]) + function["description"] = ( + f"Submit exactly one canonical {contract['atomic_id']} operation using the enforced fragment schema. " + "Legacy aliases, feature arrays, ids, dependencies and server-owned selectors are forbidden." + ) + plan_mode = str((state or {}).get("modeling_plan_mode") or "semantic_only") + required = ["batch_goal", "fragment"] + if (state or {}).get("modeling_plan_enforced") and plan_mode == "indexed": + required.insert(0, "plan_step_id") + if (state or {}).get("modeling_plan_enforced"): + required.insert(1 if "plan_step_id" in required else 0, "plan_action_id") + function["parameters"] = { + "type": "object", + "properties": { + "plan_step_id": {"type": "string", "minLength": 1}, + "plan_action_id": {"type": "string", "minLength": 1}, + "batch_goal": {"type": "string", "minLength": 8, "maxLength": 800}, + "fragment": fragment_schema, + }, + "required": required, + "additionalProperties": False, + } + return tool + def _author_tools( self, task: dict[str, Any], @@ -930,9 +1930,74 @@ class AutonomousCdslGenerationRunner: str((tool.get("function") or {}).get("name") or ""): tool for tool in self.tools } + conclusion_tool = deepcopy(by_name["record_geometry_conclusion"]) + rejection_state = (state or {}).get("geometry_rejection") + rejection_fingerprint = str((rejection_state or {}).get("geometry_fingerprint") or "") if isinstance(rejection_state, dict) else "" + diagnosis_state = ((state or {}).get("geometry_diagnoses_by_fingerprint") or {}).get(rejection_fingerprint) or {} + diagnosis_observations = diagnosis_state.get("observations") if isinstance(diagnosis_state, dict) else {} + current_evidence_refs = sorted({ + str(item.get("ref") or "") + for item in (diagnosis_observations or {}).values() + if isinstance(item, dict) and str(item.get("ref") or "") + }) + if current_evidence_refs: + evidence_schema = conclusion_tool["function"]["parameters"]["properties"]["evidence_refs"]["items"] + evidence_schema.pop("minLength", None) + evidence_schema["enum"] = current_evidence_refs + conclusion_tool["function"]["description"] += " Copy evidence_refs exactly from the enum; arbitrary text and paths are invalid." + by_name["record_geometry_conclusion"] = conclusion_tool + # Keep operation selection closed over the installed Runtime. A + # free-form string here lets the model repeatedly ask for aliases + # such as ``extrude_cut`` even though the backend rejects them. An + # operation-specific enum makes unsupported IDs unselectable at the + # tool boundary while the backend remains the final authority. + contract_tool = deepcopy(by_name["get_cdsl_operation_contract"]) + try: + runtime_ids = sorted( + str(item) for item in getattr(load_engine(self.settings), "SUPPORTED_ATOMIC_IDS", ()) if str(item) + ) + except Exception: + runtime_ids = [] + if runtime_ids: + active_contract = self._active_contract(task, state) + active_atomic_id = str((active_contract or {}).get("atomic_id") or "") + selectable_runtime_ids = [item for item in runtime_ids if item != active_atomic_id] or runtime_ids + contract_parameters = contract_tool["function"].setdefault("parameters", {}) + atomic_schema = contract_parameters.setdefault("properties", {}).setdefault("atomic_id", {}) + atomic_schema.pop("minLength", None) + atomic_schema["enum"] = selectable_runtime_ids + contract_tool["function"]["description"] = ( + "Read the exact Runtime contract for one supported atomic operation. " + "atomic_id must be copied exactly from the enum; aliases and prefixes are invalid." + ) + by_name["get_cdsl_operation_contract"] = contract_tool + canonical_submit = self._canonical_submit_tool(task, state) + + def tools_for(*names: str) -> list[dict[str, Any]]: + selected: list[dict[str, Any]] = [] + seen: set[str] = set() + for name in names: + tool = canonical_submit if name == "submit_cdsl_fragment" else by_name[name] + if tool is None: + tool = by_name["get_cdsl_operation_contract"] + actual = str((tool.get("function") or {}).get("name") or "") + if actual and actual not in seen: + selected.append(tool) + seen.add(actual) + return selected if (state or {}).get("completion_checklist_required") and not (state or {}).get("completion_checklist_written"): return [by_name["write_completion_checklist"]] + # New autonomous runs must establish and independently review a + # modeling plan before exposing any geometry action. Legacy tests and + # persisted tasks without this marker retain the original protocol. + if (state or {}).get("modeling_plan_enforced"): + plan_status = str((state or {}).get("modeling_plan_status") or "missing") + if plan_status in {"missing", "revise"}: + return [by_name["write_modeling_plan"]] + if plan_status != "approved": + return [] + completion_state_enforced = any( key in (state or {}) for key in ("completion_checklist_required", "completion_checklist_written", "completion_ledger") @@ -948,6 +2013,10 @@ class AutonomousCdslGenerationRunner: and bool(ledger.get("items")) and all(str(item.get("status") or "") == "complete" for item in ledger.get("items") or () if isinstance(item, dict)) ) + if (state or {}).get("modeling_plan_enforced"): + plan_status = str((state or {}).get("modeling_plan_status") or "") + step_status = (state or {}).get("plan_step_status") if isinstance((state or {}).get("plan_step_status"), dict) else {} + ready = ready and plan_status == "approved" and bool(step_status) and all(value in {"complete", "skipped"} for value in step_status.values()) # Coverage is supplied only by the independent candidate reviewer. # Letting the author write the final audit again would restore the # self-certification path this protocol removes. @@ -1008,7 +2077,7 @@ class AutonomousCdslGenerationRunner: return [by_name[name] for name in tool_names] + [by_name["record_geometry_conclusion"]] decision = str(conclusion.get("decision") or "") if decision == "modify": - return [by_name["submit_cdsl_fragment"], by_name["rollback_checkpoint"]] + return tools_for("submit_cdsl_fragment", "rollback_checkpoint") if decision == "rollback": return [by_name["rollback_checkpoint"]] if decision == "complete": @@ -1034,6 +2103,18 @@ class AutonomousCdslGenerationRunner: # pre-review run or an interrupted reviewer call, so the author # must not be offered a self-review or self-commit escape hatch. return [by_name["rollback_checkpoint"]] + completion_proof = self._current_completion_proof(task, state or {}) + current_plan_step = self._plan_current_step(state or {}) + if ( + completion_proof is not None + and (state or {}).get("modeling_plan_enforced") + and isinstance(current_plan_step, dict) + and str(current_plan_step.get("status") or "pending") not in {"complete", "skipped"} + ): + # The frozen completion contract has already been independently + # verified on this exact revision. Do not force a redundant CDSL + # candidate merely to discover that the active plan step is a no-op. + return [by_name["skip_satisfied_plan_step"]] correction = (state or {}).get("format_correction") if ( isinstance(correction, dict) @@ -1052,34 +2133,67 @@ class AutonomousCdslGenerationRunner: correction = {} action_required = (state or {}).get("candidate_action_required") if isinstance(action_required, dict) and str(action_required.get("working_head") or "") == self._head_key(task): + if str(action_required.get("reason") or "") == "operation_contract_required": + return [by_name["get_cdsl_operation_contract"]] + if str(action_required.get("reason") or "") == "unsupported_operation": + return [by_name["get_cdsl_operation_contract"], by_name["inspect_model"]] + if str(action_required.get("reason") or "") == "unsupported_operation_limit": + return [by_name["rollback_checkpoint"], by_name["inspect_model"]] if str(action_required.get("reason") or "") == "edge_selector_recovery": return [by_name["inspect_topology"]] if str(action_required.get("reason") or "") == "edge_selector_recovery_submit": - return [by_name["submit_cdsl_fragment"], by_name["rollback_checkpoint"]] + return tools_for("submit_cdsl_fragment", "rollback_checkpoint") if str(action_required.get("reason") or "") == "edge_selector_recovery_no_edges": return [by_name["rollback_checkpoint"]] + if str(action_required.get("reason") or "") == "topology_selector_recovery": + return [by_name["inspect_topology"], by_name["rollback_checkpoint"]] + if str(action_required.get("reason") or "") == "topology_selector_recovery_submit": + return tools_for("submit_cdsl_fragment", "rollback_checkpoint") + if str(action_required.get("reason") or "") == "topology_selector_recovery_no_tokens": + return [by_name["rollback_checkpoint"]] + if str(action_required.get("reason") or "") == "engine_geometry_invalid": + return tools_for("inspect_topology", "measure_model", "render_views", "render_section", "get_cdsl_operation_contract", "submit_cdsl_fragment", "rollback_checkpoint") if str(action_required.get("reason") or "") == "duplicate_repair_observation": - return [by_name["submit_cdsl_fragment"], by_name["rollback_checkpoint"]] + return tools_for("submit_cdsl_fragment", "rollback_checkpoint") if str(action_required.get("reason") or "") == "candidate_review_rejected": # The rejected candidate's render, deterministic report and # structured reviewer evidence are already in context. More # generic inspection cannot alter that evidence and was a # recurring source of no-progress loops. - return [by_name["submit_cdsl_fragment"], by_name["rollback_checkpoint"]] + return tools_for("submit_cdsl_fragment", "rollback_checkpoint") + if str(action_required.get("reason") or "") == "review_service_failure": + # A malformed reviewer response is not evidence that the + # geometry is wrong. Prevent an authoring loop; the retained + # candidate must be retried by the review service or abandoned + # through rollback. + return [by_name["rollback_checkpoint"]] + if str(action_required.get("reason") or "") == "global_invariant_violation": + return tools_for("submit_cdsl_fragment", "rollback_checkpoint") + if str(action_required.get("reason") or "") == "optional_finish_failed": + # A cosmetic edge finish is deliberately non-blocking once + # the core checklist is independently complete. Keep the + # evidence tools available, but do not reopen a CDSL repair + # loop for the failed optional operation. + return [*repair_observation_tools(), *completion_action_tools(), by_name["rollback_checkpoint"]] if str(action_required.get("reason") or "") == "candidate_attempt_limit": # The build budget is exhausted. More observations cannot # make a rejected fragment executable; the remaining durable # choices are to retreat or, if its checklist permits, ask # for final publication of the current checkpoint. return [by_name["rollback_checkpoint"], *completion_action_tools()] + if str(action_required.get("reason") or "") == "canonical_format_limit": + self._clear_active_operation_contract(state) + return [by_name["get_cdsl_operation_contract"], by_name["rollback_checkpoint"]] if str(action_required.get("reason") or "") == "duplicate_fragment_limit": return [by_name["rollback_checkpoint"]] + if str(action_required.get("reason") or "") == "repeated_cdsl_attempt": + return tools_for("submit_cdsl_fragment", "rollback_checkpoint") return [*repair_observation_tools(), by_name["rollback_checkpoint"]] if isinstance(correction, dict) and str(correction.get("working_head") or "") == self._head_key(task): # The server has already determined the exact local contract. # Force a direct correction before further observations can bury # that information in history or spend another retry cycle. - return [by_name["submit_cdsl_fragment"]] + return tools_for("submit_cdsl_fragment") rejection = noop_rejection() if rejection is not None: @@ -1099,15 +2213,22 @@ class AutonomousCdslGenerationRunner: # no-progress budget still bounds purely observational loops. if not final_repair.get("topology_observed"): return [by_name[name] for name in ("inspect_topology", "rollback_checkpoint")] - return [*repair_observation_tools(), by_name["get_cdsl_operation_contract"], by_name["submit_cdsl_fragment"], by_name["rollback_checkpoint"]] + return [*repair_observation_tools(), *tools_for("get_cdsl_operation_contract", "submit_cdsl_fragment", "rollback_checkpoint")] # The root head is already described in every author context as an # empty model. Re-exposing read-only inspection here creates an # unproductive loop: no observation can reveal more state until the - # first additive feature exists. Force the author to establish that - # base body; later heads regain the full observation tool set. + # first additive feature exists. The contract lookup remains available + # so the first additive operation can satisfy the same first-use + # protocol as every later operation. if not str(task.get("active_revision") or "") and not str(task.get("active_candidate_id") or ""): - return [by_name["submit_cdsl_fragment"]] + if canonical_submit is not None: + return tools_for("submit_cdsl_fragment") + recent = (state or {}).get("recent_events") or [] + last_kind = str((recent[-1] or {}).get("kind") or "") if recent and isinstance(recent[-1], dict) else "" + if last_kind == "get_cdsl_operation_contract": + return tools_for("submit_cdsl_fragment") + return tools_for("get_cdsl_operation_contract") events = (state or {}).get("recent_events") or [] last_kind = str((events[-1] or {}).get("kind") or "") if events and isinstance(events[-1], dict) else "" @@ -1123,32 +2244,24 @@ class AutonomousCdslGenerationRunner: for name in ("inspect_topology",) ] + completion_action_tools() if last_kind == "inspect_topology": - return [ - by_name[name] - for name in ("get_cdsl_operation_contract", "submit_cdsl_fragment", "measure_model", "render_views", "render_section", "rollback_checkpoint") - ] + completion_action_tools() + return tools_for("get_cdsl_operation_contract", "submit_cdsl_fragment", "measure_model", "render_views", "render_section", "rollback_checkpoint") + completion_action_tools() if last_kind in {"measure_model", "render_views", "render_section"}: - return [ - by_name[name] - for name in ("get_cdsl_operation_contract", "submit_cdsl_fragment", "rollback_checkpoint") - ] + completion_action_tools() + return tools_for("get_cdsl_operation_contract", "submit_cdsl_fragment", "rollback_checkpoint") + completion_action_tools() if last_kind == "get_cdsl_operation_contract": # A contract is requested immediately before authoring. Reopening # the full observation menu here only spends a model turn without # improving that pending CDSL decision. - return [by_name[name] for name in ("submit_cdsl_fragment", "rollback_checkpoint")] + completion_action_tools() + return tools_for("submit_cdsl_fragment", "rollback_checkpoint") + completion_action_tools() if last_kind == "tool_error": # An invalid fragment or a stale token must not funnel the author # into completion. Re-expose the current snapshot so it can # recover concrete evidence before retrying the small feature. - return [ - by_name[name] - for name in ("inspect_topology", "get_cdsl_operation_contract", "measure_model", "render_views", "render_section", "submit_cdsl_fragment", "rollback_checkpoint") - ] + completion_action_tools() - return [ - tool for name, tool in by_name.items() - if name not in {"write_requirements_document", "write_completion_checklist", "complete_task"} - ] + completion_action_tools() + return tools_for("inspect_topology", "get_cdsl_operation_contract", "measure_model", "render_views", "render_section", "submit_cdsl_fragment", "rollback_checkpoint") + completion_action_tools() + return tools_for( + "inspect_model", "read_cdsl_slice", "measure_model", "inspect_topology", + "get_cdsl_operation_contract", "render_views", "render_section", + "submit_cdsl_fragment", "record_geometry_conclusion", "rollback_checkpoint", + ) + completion_action_tools() def _state( self, @@ -1172,11 +2285,61 @@ class AutonomousCdslGenerationRunner: current.setdefault("completion_checklist_required", bool(self.store.read_requirements_document(task_id)) and not checklist_exists) current.setdefault("completion_checklist_written", checklist_exists) current.setdefault("completion_ledger", {}) + current.setdefault("modeling_plan_enforced", False) + current.setdefault("modeling_plan_written", bool(self.store.read_modeling_plan(task_id))) + current.setdefault("modeling_plan_status", "approved" if current.get("modeling_plan_written") else "missing") + current.setdefault("modeling_plan_version", int((task.get("modeling_plan_version") or 0))) + current.setdefault("modeling_plan_review_attempts", 0) + current.setdefault("modeling_plan", {}) + current.setdefault("modeling_plan_mode", "semantic_only") + current.setdefault("plan_source_hash", "") + current.setdefault("active_plan_step_id", "") + current.setdefault("active_plan_action_id", "") + current.setdefault("plan_action_status", {}) + current.setdefault("completed_plan_actions", []) + current.setdefault("plan_feature_status", {}) + current.setdefault("plan_step_status", {}) + current.setdefault("best_known_checkpoint", str(task.get("active_revision") or "")) current.setdefault("geometry_diagnoses_by_fingerprint", {}) current.setdefault("rejected_fragment_fingerprints_by_geometry", {}) current.setdefault("duplicate_fragment_rejections_by_geometry", {}) current.setdefault("edge_selector_recovery", {}) + current.setdefault("topology_observation", {}) + current.setdefault("engine_geometry_failure", {}) current.setdefault("last_candidate_review", {}) + current.setdefault("read_operation_contract_ids", []) + current.setdefault("active_operation_contract", {}) + current.setdefault("pending_operation", "") + current.setdefault("pending_operation_contract_hash", "") + current.setdefault("pending_operation_revision", "") + current.setdefault("schema_retry_counts", {}) + current.setdefault("selector_recovery_counts", {}) + current.setdefault("geometry_retry_counts", {}) + current.setdefault("unsupported_operation_counts", {}) + current.setdefault("optional_finish", {}) + active_contract = current.get("active_operation_contract") + if isinstance(active_contract, dict) and active_contract.get("atomic_id"): + try: + atomic_id = str(active_contract.get("atomic_id") or "") + runtime_schema = build_operation_fragment_schema(load_engine(self.settings), atomic_id) + valid_hash = operation_contract_hash(atomic_id, runtime_schema) + if ( + str(current.get("pending_operation_revision") or "") != str(task.get("active_revision") or "") + or str(current.get("pending_operation_contract_hash") or "") != valid_hash + ): + self._clear_active_operation_contract(current) + except (ValueError, KeyError, RuntimeError): + self._clear_active_operation_contract(current) + if not current.get("modeling_plan"): + plan_text = self.store.read_modeling_plan(task_id) + if plan_text: + try: + current["modeling_plan"] = parse_modeling_plan(plan_text, checklist=self._completion_items(task_id)) + current["modeling_plan_mode"] = str(current["modeling_plan"].get("mode") or "semantic_only") + current["modeling_plan"]["plan_version"] = int(current.get("modeling_plan_version") or task.get("modeling_plan_version") or 1) + self._initialize_plan_actions(current) + except AutonomousGenerationError: + current["modeling_plan_status"] = "stale" final_repair = current.get("final_repair") if isinstance(final_repair, dict) and str(final_repair.get("working_head") or "") == self._head_key(task): # Pre-v2.1 state had no provenance for retained final-review @@ -1211,9 +2374,36 @@ class AutonomousCdslGenerationRunner: "duplicate_fragment_rejections_by_geometry": {}, "candidate_action_required": {}, "edge_selector_recovery": {}, + "topology_observation": {}, + "engine_geometry_failure": {}, + "read_operation_contract_ids": [], + "active_operation_contract": {}, + "pending_operation": "", + "pending_operation_contract_hash": "", + "pending_operation_revision": "", + "schema_retry_counts": {}, + "selector_recovery_counts": {}, + "geometry_retry_counts": {}, + "unsupported_operation_counts": {}, + "optional_finish": {}, "completion_checklist_required": False, "completion_checklist_written": False, "completion_ledger": {}, + "modeling_plan_enforced": False, + "modeling_plan_written": False, + "modeling_plan_status": "missing", + "modeling_plan_version": 0, + "modeling_plan_review_attempts": 0, + "modeling_plan": {}, + "modeling_plan_mode": "semantic_only", + "plan_source_hash": "", + "active_plan_step_id": "", + "active_plan_action_id": "", + "plan_action_status": {}, + "completed_plan_actions": [], + "plan_feature_status": {}, + "plan_step_status": {}, + "best_known_checkpoint": "", "completed_repair_observations_by_head": {}, "repair_observation_counts_by_head": {}, "repair_observation_keys_by_head": {}, @@ -1230,7 +2420,12 @@ class AutonomousCdslGenerationRunner: def _record(self, state: dict[str, Any], kind: str, payload: dict[str, Any]) -> None: event_payload = {"kind": kind, "at": now_iso(), **payload} state.setdefault("recent_events", []).append(event_payload) - state["last_diagnostic"] = str(payload.get("message") or payload.get("error") or state.get("last_diagnostic") or "") + diagnostic = payload.get("diagnostic") + if isinstance(diagnostic, dict): + state["last_diagnostic"] = diagnostic + else: + previous = state.get("last_diagnostic") + state["last_diagnostic"] = str(payload.get("message") or payload.get("error") or (previous if isinstance(previous, str) else "")) def _completion_items(self, task_id: str) -> list[str]: checklist = self.store.read_completion_checklist(task_id) @@ -1327,7 +2522,14 @@ class AutonomousCdslGenerationRunner: can be requested again through its dedicated read tool. """ private_keys = {"candidate_id", "revision_id", "branch_id", "path", "working_head"} - entries = [raw for raw in state.get("recent_events") or () if isinstance(raw, dict)] + # Compatibility migrations and raw fragment audit records are useful + # for operators, but are dangerous few-shot examples for an author: + # they make legacy aliases look like successful canonical syntax. + hidden_kinds = {"compatibility_normalized", "fragment"} + entries = [ + raw for raw in state.get("recent_events") or () + if isinstance(raw, dict) and str(raw.get("kind") or "") not in hidden_kinds + ] detailed_indexes = [ index for index, raw in enumerate(entries) if isinstance(raw.get("result"), dict) or raw.get("kind") in {"read_cdsl_slice", "get_cdsl_operation_contract"} @@ -1342,6 +2544,324 @@ class AutonomousCdslGenerationRunner: result.append(sanitized) return result + def _current_topology_observation( + self, + task_id: str, + task: dict[str, Any], + state: dict[str, Any], + *, + topology: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + """Return the one durable token bank valid for the active snapshot.""" + observation = state.get("topology_observation") + if not isinstance(observation, dict): + return None + if str(observation.get("working_head") or "") != self._head_key(task): + return None + if topology is None: + _, topology, _, _ = self._artifact_data(task_id, task) + snapshot_id = str((topology or {}).get("snapshot_id") or "") + if not snapshot_id or str(observation.get("snapshot_id") or "") != snapshot_id: + return None + raw_tokens = observation.get("tokens") + if not isinstance(raw_tokens, list): + return None + tokens: list[dict[str, Any]] = [] + for item in raw_tokens: + if not isinstance(item, dict): + return None + token = str(item.get("token") or "") + kind = str(item.get("kind") or "") + geometry = item.get("geometry") + if not token or kind not in {"face", "edge", "vertex", "plane", "axis", "body"} or not isinstance(geometry, dict): + return None + tokens.append({"token": token, "kind": kind, "geometry": deepcopy(geometry)}) + return {"working_head": self._head_key(task), "snapshot_id": snapshot_id, "tokens": tokens} + + def _plan_current_step(self, state: dict[str, Any]) -> dict[str, Any] | None: + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + steps = [item for item in plan.get("steps") or () if isinstance(item, dict)] + active_id = str(state.get("active_plan_step_id") or "") + if active_id: + found = next((item for item in steps if str(item.get("step_id") or "") == active_id), None) + if found is not None: + return found + for step in steps: + if str(step.get("status") or "pending") not in {"complete", "skipped"}: + return step + return steps[-1] if steps else None + + def _plan_current_action(self, state: dict[str, Any]) -> dict[str, Any] | None: + """Return the next required action inside the active semantic step.""" + step = self._plan_current_step(state) + if not isinstance(step, dict): + return None + actions = [item for item in step.get("actions") or () if isinstance(item, dict)] + if not actions: + return None + active_id = str(state.get("active_plan_action_id") or "") + statuses = state.get("plan_action_status") if isinstance(state.get("plan_action_status"), dict) else {} + if active_id: + found = next((item for item in actions if str(item.get("action_id") or "") == active_id), None) + if found is not None and statuses.get(active_id, found.get("status", "pending")) not in {"complete", "skipped", "satisfied_by_prior_step"}: + return found + return next( + (item for item in actions + if statuses.get(str(item.get("action_id") or ""), item.get("status", "pending")) not in {"complete", "skipped", "satisfied_by_prior_step"} + and bool(item.get("required", True))), + next((item for item in actions if statuses.get(str(item.get("action_id") or ""), item.get("status", "pending")) not in {"complete", "skipped", "satisfied_by_prior_step"}), None), + ) + + def _initialize_plan_actions(self, state: dict[str, Any]) -> None: + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + actions = [item for item in plan.get("actions") or () if isinstance(item, dict)] + statuses = state.setdefault("plan_action_status", {}) + for action in actions: + action_id = str(action.get("action_id") or "") + if action_id: + statuses.setdefault(action_id, str(action.get("status") or "pending")) + if not str(state.get("active_plan_action_id") or ""): + action = self._plan_current_action(state) + state["active_plan_action_id"] = str((action or {}).get("action_id") or "") + state["completed_plan_actions"] = [key for key, value in statuses.items() if value in {"complete", "satisfied_by_prior_step"}] + + def _plan_prompt_context(self, state: dict[str, Any]) -> dict[str, Any] | None: + if not state.get("modeling_plan_enforced") or str(state.get("modeling_plan_status") or "") != "approved": + return None + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + step = self._plan_current_step(state) + if step is None: + return None + feature_by_id = { + str(item.get("feature_id") or ""): item + for item in plan.get("features") or () if isinstance(item, dict) + } + features = [feature_by_id[item] for item in step.get("feature_ids") or () if item in feature_by_id] + self._initialize_plan_actions(state) + current_action = self._plan_current_action(state) + actions = [item for item in step.get("actions") or () if isinstance(item, dict)] + completed_actions = [key for key, value in (state.get("plan_action_status") or {}).items() if value in {"complete", "satisfied_by_prior_step"}] + step_ids = [str(item.get("step_id") or "") for item in plan.get("steps") or () if isinstance(item, dict)] + current_index = step_ids.index(str(step.get("step_id") or "")) if str(step.get("step_id") or "") in step_ids else -1 + return { + "plan_version": plan.get("plan_version"), + "plan_mode": str(plan.get("mode") or state.get("modeling_plan_mode") or "semantic_only"), + "current_step": deepcopy(step), + "current_step_features": deepcopy(features), + "current_action": deepcopy(current_action), + "current_step_actions": deepcopy(actions), + "completed_actions": completed_actions, + "remaining_actions": [str(item.get("action_id") or "") for item in actions if str(item.get("action_id") or "") not in completed_actions], + "completed_feature_ids": [key for key, value in (state.get("plan_feature_status") or {}).items() if value == "complete"], + "satisfied_by_prior_feature_ids": [key for key, value in (state.get("plan_feature_status") or {}).items() if value == "satisfied_by_prior_step"], + "step_status": state.get("plan_step_status") or {}, + "future_steps": [ + deepcopy(item) + for index, item in enumerate(plan.get("steps") or ()) + if isinstance(item, dict) + and index > current_index + and str(item.get("status") or "pending") not in {"complete", "skipped"} + ], + "instruction": "A semantic modeling step may contain multiple related actions. Generate only the current action and submit exactly one feature per fragment with plan_step_id and plan_action_id. Left and right related slots may stay in one step, but must use separate fragments and checkpoints. Do not implement features belonging to future_steps early. If the current model already satisfies a future step, collect evidence and record a skip conclusion.", + } + + def _plan_gate_error(self, state: dict[str, Any]) -> str | None: + if not state.get("modeling_plan_enforced"): + return None + if str(state.get("modeling_plan_status") or "") != "approved": + return "PLAN_REQUIRED: modeling plan must be approved before final publication" + steps = state.get("plan_step_status") if isinstance(state.get("plan_step_status"), dict) else {} + incomplete = [key for key, value in steps.items() if value not in {"complete", "skipped"}] + action_status = state.get("plan_action_status") if isinstance(state.get("plan_action_status"), dict) else {} + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + incomplete_actions = [str(item.get("action_id") or "") for item in plan.get("actions") or () if isinstance(item, dict) and item.get("required", True) and action_status.get(str(item.get("action_id") or ""), "pending") not in {"complete", "skipped", "satisfied_by_prior_step"}] + if incomplete: + return "PLAN_INCOMPLETE: unresolved modeling-plan steps: " + ", ".join(incomplete) + if incomplete_actions: + return "PLAN_INCOMPLETE: unresolved modeling-plan actions: " + ", ".join(incomplete_actions) + return None + + def _current_completion_proof(self, task: dict[str, Any], state: dict[str, Any]) -> dict[str, Any] | None: + """Return independent completion evidence bound to the active revision.""" + revision_id = str(task.get("active_revision") or "") + ledger = state.get("completion_ledger") if isinstance(state.get("completion_ledger"), dict) else {} + raw_items = ledger.get("items") + if not isinstance(raw_items, list) or not raw_items or any(not isinstance(item, dict) for item in raw_items): + return None + items = list(raw_items) + task_id = str(task.get("task_id") or "") + checklist = self._completion_items(task_id) if task_id else [] + if checklist: + expected = [_checklist_key(item) for item in checklist] + actual = [_checklist_key(str(item.get("item") or "")) for item in items] + if actual != expected: + return None + if ( + not revision_id + or str(ledger.get("verified_revision") or "") != revision_id + or any(str(item.get("status") or "") != "complete" for item in items) + ): + return None + return { + "revision_id": revision_id, + "evidence_ref": f"completion_ledger:{revision_id}", + "item_count": len(items), + "items": [ + {"item": str(item.get("item") or ""), "evidence": str(item.get("evidence") or "")} + for item in items + ], + } + + def _mark_plan_progress(self, state: dict[str, Any], candidate: dict[str, Any]) -> None: + if not state.get("modeling_plan_enforced"): + return + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + step_id = str(candidate.get("plan_step_id") or "") + if not step_id: + return + step = next((item for item in plan.get("steps") or () if isinstance(item, dict) and str(item.get("step_id") or "") == step_id), None) + if not isinstance(step, dict): + return + self._initialize_plan_actions(state) + feature_status = state.setdefault("plan_feature_status", {}) + action_status = state.setdefault("plan_action_status", {}) + feature_ids = {str(value) for value in step.get("feature_ids") or () if str(value)} + planned_features = [ + item for item in plan.get("features") or () + if isinstance(item, dict) and str(item.get("feature_id") or "") in feature_ids + ] + action_id = str(candidate.get("plan_action_id") or "") + actions = [item for item in step.get("actions") or () if isinstance(item, dict)] + action = next((item for item in actions if str(item.get("action_id") or "") == action_id), None) if action_id else None + if action is not None: + action_status[action_id] = "complete" + action["status"] = "complete" + linked_feature = str(action.get("feature_id") or "") + if linked_feature: + feature_status[linked_feature] = "complete" + elif str(action.get("operation") or ""): + match = next( + (item for item in planned_features + if str(item.get("operation") or "") == str(action.get("operation") or "") + and feature_status.get(str(item.get("feature_id") or "")) != "complete"), + None, + ) + if isinstance(match, dict) and str(match.get("feature_id") or ""): + feature_status[str(match.get("feature_id"))] = "complete" + operations = [str(value) for value in candidate.get("atomic_ids") or () if str(value)] + for operation in operations: + if action is not None: + break + match = next( + (item for item in planned_features + if str(item.get("operation") or "") == operation + and feature_status.get(str(item.get("feature_id") or "")) != "complete"), + None, + ) + if isinstance(match, dict): + feature_status[str(match.get("feature_id") or "")] = "complete" + step_status = state.setdefault("plan_step_status", {}) + required_actions = [item for item in actions if item.get("required", True)] + actions_complete = all(action_status.get(str(item.get("action_id") or "")) in {"complete", "skipped", "satisfied_by_prior_step"} for item in required_actions) + features_complete = all(feature_status.get(str(item.get("feature_id") or "")) == "complete" for item in planned_features) if planned_features else True + step_status[step_id] = "complete" if actions_complete and features_complete else "pending" + for item in plan.get("steps") or (): + if isinstance(item, dict) and str(item.get("step_id") or "") == step_id: + item["status"] = step_status[step_id] + next_step = next((item for item in plan.get("steps") or () if isinstance(item, dict) and str(item.get("status") or "pending") not in {"complete", "skipped"}), None) + state["active_plan_step_id"] = str(next_step.get("step_id") or "") if isinstance(next_step, dict) else "" + state["active_plan_action_id"] = "" + self._initialize_plan_actions(state) + + def _mark_plan_step_skipped( + self, + state: dict[str, Any], + *, + evidence_refs: list[str], + reason: str, + ) -> dict[str, Any] | None: + """Advance a plan step already satisfied by the current geometry.""" + if not state.get("modeling_plan_enforced"): + return None + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + step = self._plan_current_step(state) + if not isinstance(step, dict): + return None + step_id = str(step.get("step_id") or state.get("active_plan_step_id") or "") + if not step_id: + return None + state.setdefault("plan_step_status", {})[step_id] = "skipped" + feature_status = state.setdefault("plan_feature_status", {}) + action_status = state.setdefault("plan_action_status", {}) + for feature_id in step.get("feature_ids") or (): + key = str(feature_id or "") + if key: + feature_status[key] = "satisfied_by_prior_step" + for action in step.get("actions") or (): + if isinstance(action, dict) and str(action.get("action_id") or ""): + action_id = str(action["action_id"]) + action_status[action_id] = "satisfied_by_prior_step" + action["status"] = "satisfied_by_prior_step" + step["status"] = "skipped" + step["satisfied_by_prior_step"] = True + step["skip_reason"] = str(reason).strip() + step["skip_evidence_refs"] = list(dict.fromkeys(str(item) for item in evidence_refs if str(item))) + next_step = next( + (item for item in plan.get("steps") or () + if isinstance(item, dict) and str(item.get("status") or "pending") not in {"complete", "skipped"}), + None, + ) + state["active_plan_step_id"] = str(next_step.get("step_id") or "") if isinstance(next_step, dict) else "" + state["active_plan_action_id"] = "" + self._initialize_plan_actions(state) + return step + + def _restore_plan_after_rollback(self, state: dict[str, Any], task: dict[str, Any], revision_id: str) -> None: + if not state.get("modeling_plan_enforced"): + return + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + steps = [item for item in plan.get("steps") or () if isinstance(item, dict)] + target_step_id = "" + if revision_id: + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and str(item.get("revision_id") or "") == revision_id), None) + target_step_id = str((revision or {}).get("plan_step_id") or "") + target_index = next((index for index, item in enumerate(steps) if str(item.get("step_id") or "") == target_step_id), -1) + self._initialize_plan_actions(state) + feature_status = state.setdefault("plan_feature_status", {}) + action_status = state.setdefault("plan_action_status", {}) + step_status = state.setdefault("plan_step_status", {}) + target_revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and str(item.get("revision_id") or "") == revision_id), {}) if revision_id else {} + target_action_id = str(target_revision.get("plan_action_id") or "") + for index, step in enumerate(steps): + step_id = str(step.get("step_id") or "") + prior_step = target_index >= 0 and index < target_index + target_step = target_index >= 0 and index == target_index + for feature_id in step.get("feature_ids") or (): + feature_status[str(feature_id)] = "complete" if prior_step else "pending" + actions = [item for item in step.get("actions") or () if isinstance(item, dict)] + target_action_index = next((i for i, item in enumerate(actions) if str(item.get("action_id") or "") == target_action_id), -1) + for action_index, action in enumerate(actions): + if not isinstance(action, dict): + continue + action_id = str(action.get("action_id") or "") + if not action_id: + continue + action_complete = prior_step or (target_step and target_action_index >= 0 and action_index <= target_action_index) + action_status[action_id] = "complete" if action_complete else "pending" + action["status"] = action_status[action_id] + if action_complete and str(action.get("feature_id") or ""): + feature_status[str(action.get("feature_id"))] = "complete" + required_actions = [item for item in actions if item.get("required", True)] + step_complete = bool(actions) and all(action_status.get(str(item.get("action_id") or "")) == "complete" for item in required_actions) + if not actions: + step_complete = prior_step + step["status"] = "complete" if step_complete else "pending" + step_status[step_id] = step["status"] + next_step = next((item for item in steps if str(item.get("status") or "pending") not in {"complete", "skipped"}), None) + state["active_plan_step_id"] = str(next_step.get("step_id") or "") if isinstance(next_step, dict) else "" + state["active_plan_action_id"] = "" + self._initialize_plan_actions(state) + def _prompt_messages(self, task_id: str, state: dict[str, Any], engine: Any) -> list[dict[str, Any]]: task = self.store.read_task(task_id) or {} requirements = self.store.read_requirements_document(task_id) @@ -1351,9 +2871,12 @@ class AutonomousCdslGenerationRunner: diagnosis = ((state.get("geometry_diagnoses_by_fingerprint") or {}).get(current_fingerprint) or {}) observations = diagnosis.get("observations") if isinstance(diagnosis, dict) else {} context = { + "visible_response_language": _response_language(str(state.get("request") or source_requirements or "")), + "visible_response_language_rule": "Every visible word outside code, identifiers, and units must use visible_response_language.", "source_requirements_markdown": source_requirements or "No source-requirements.md artifact is available.", "requirements_markdown": requirements or "requirements.md has not been written yet.", "completion_coverage": self._completion_context(task_id, task, state), + "modeling_plan": self._plan_prompt_context(state), "current_model": model_summary, "last_step_review": { key: value @@ -1373,16 +2896,22 @@ class AutonomousCdslGenerationRunner: "conclusion": diagnosis.get("conclusion") if isinstance(diagnosis, dict) else None, }, "candidate_action_required": state.get("candidate_action_required") or None, - # This survives event trimming. It is the only token bank a - # finish-recovery fragment may reuse. + "engine_geometry_failure": state.get("engine_geometry_failure") or None, + # This survives event trimming. It is the only token bank any + # topology-sensitive fragment may reuse, and disappears as soon + # as its checkpoint or runtime snapshot changes. + "current_topology_tokens": self._current_topology_observation(task_id, task, state), "edge_selector_recovery": state.get("edge_selector_recovery") or None, "final_repair": state.get("final_repair") or None, + # Keep the active contract in durable context. It must not be + # hidden in recent events where context trimming can remove it. + "active_operation_contract": state.get("active_operation_contract") or None, "runtime_operations": _runtime_summary(engine), # A compact, always-present protocol. Exact operation fields are # provided on demand by get_cdsl_operation_contract, avoiding a # full tutorial for holes, revolves and edge finishes every turn. "cdsl_authoring_basics": { - "fragment_shape": {"sketch": "optional {workplane, profile}", "feature": "{atomic_id, params, optional selector_tokens}"}, + "fragment_shape": {"sketch": "required only when the active contract says requires_sketch", "feature": "exactly one {atomic_id, params, optional selector_tokens}"}, "workplane": { "origin_mm": "[x,y,z]", "x_dir": "[x,y,z]", @@ -1391,22 +2920,25 @@ class AutonomousCdslGenerationRunner: }, "profiles": {"supported_types": ["circle", "polygon", "analytic_contours"], "polygon": "ordered [u,v] vertices", "circle": "radius_mm plus optional center"}, "rules": [ + "Every submission contains exactly one operation and exactly one feature. The active operation contract is authoritative; legacy aliases, feature arrays, wrappers and unknown fields are invalid.", "Do not supply ids, dependencies, sketch_id, raw selectors, named planes, or unknown fields.", "Use a workplane object with origin_mm, x_dir and normal for every sketch. A profile point [u,v] maps to origin_mm + u*x_dir + v*(normal cross x_dir); calculate the basis before choosing signs.", "Use selector_tokens only from the current inspect_topology result.", - "Before every atomic operation that is not already in the current feature list, call get_cdsl_operation_contract for that exact atomic_id. Never infer one operation's params from a similarly named operation.", - "Every revolve_add or revolve_cut needs an author-defined axis: params.axis={origin_mm:[x,y,z],direction:[dx,dy,dz]}. For a same-axis batch, put that object once in top-level revolve_axis; never request or use an axis selector token.", + "Before every atomic operation, call get_cdsl_operation_contract for that exact atomic_id. Never infer one operation's params from a similarly named operation.", + "Every revolve_add or revolve_cut needs explicit params.angle_deg and params.axis={origin_mm:[x,y,z],direction:[dx,dy,dz]}. Never use shared_revolve_axis, top-level revolve_axis, or an axis selector token.", "For an X-Z tooth profile extruded across a Y-wide bar, verify the basis explicitly: origin [0,9,0], x_dir [1,0,0], normal [0,-1,0] makes local v point to global +Z and a positive blind extrusion travel toward -Y. This is a coordinate example only; choose dimensions from the frozen requirements.", "One hole feature may include multiple positions when they share one host face, diameter and depth.", ], }, } + if state.get("modeling_plan_enforced") and str(state.get("modeling_plan_status") or "missing") in {"missing", "revise"}: + context["planning_references"] = _planning_reference_context(self.settings, requirements or source_requirements) if not task.get("active_revision") and not task.get("active_candidate_id") and requirements: # This is protocol documentation for the LLM, not a geometry # template or server-side lowering rule. The author still selects # every workplane, profile and dimension from requirements.md. context["root_fragment_reference"] = { - "instruction": "The model is empty. Create its first solid now with one sketch and extrude_add_blind. submit_cdsl_fragment takes one JSON string containing this fragment object; do not wrap it in a complete CDSL document.", + "instruction": "The model is empty. Create its first solid now with one sketch and extrude_add_blind. submit_cdsl_fragment takes a structured fragment object; do not wrap it in a complete CDSL document.", "fragment_shape": { "sketch": { "workplane": { @@ -1441,7 +2973,19 @@ class AutonomousCdslGenerationRunner: for item in context["runtime_operations"] ] encoded = json.dumps(context, ensure_ascii=False) + if len(encoded) > limit: + # Keep the non-negotiable author contracts and current CAD + # state, while dropping verbose prose/evidence before any + # contract can be trimmed. The provider may receive a larger + # message than the configured soft budget, but it will never + # receive a turn without the active operation schema. + for key in ("last_candidate_review", "last_step_review", "engine_geometry_failure", "geometry_diagnosis", "geometry_rejection", "final_repair", "runtime_operations", "cdsl_authoring_basics"): + context[key] = None + encoded = json.dumps(context, ensure_ascii=False) + if len(encoded) <= limit: + break requirements_frozen = bool(requirements) + response_language = _response_language(str(state.get("request") or source_requirements or "")) requirements_protocol = ( "Your first and only action must be write_requirements_document. " "Write the complete requirements.md before any observation or modelling tool. It must preserve every source requirement; it may only add clarifying assumptions, never remove, replace, or weaken source intent. " @@ -1450,6 +2994,7 @@ class AutonomousCdslGenerationRunner: ) system = ( "You are an autonomous CDSL CAD author. " + f"All visible Agent prose, explanations, requirement markdown, checklist text, and tool status messages must be written in {response_language}, matching the user's request language. Keep CAD identifiers, JSON keys, units, and code syntax unchanged. " + requirements_protocol + ( "Now write_completion_checklist: a short frozen Markdown list of the independently observable completion claims from requirements.md. " @@ -1457,17 +3002,29 @@ class AutonomousCdslGenerationRunner: if requirements_frozen and state.get("completion_checklist_required") and not state.get("completion_checklist_written") else "Use completion_coverage from the independent reviewer as the running missing-work list. Publication is blocked unless its ledger marks every frozen item complete at the current checkpoint. " ) + + ( + "Now call write_modeling_plan before any CAD observation or CDSL. Write a clear semantic plan in ordinary Markdown or prose; use readable headings such as ## Step 1 - Base and ## Step 2 - Support so execution can keep related work together. When a step contains multiple independently located host regions, profiles, or Runtime features, it MUST enumerate one action per target using headings such as ### Action 3a - Target A and ### Action 3b - Target B, or [ACTION target_a] and [ACTION target_b]. Keep related actions in the same semantic step rather than turning each target into an unrelated step. Each action must be executable as exactly one Runtime feature. An operation that natively supports several positions or a pattern may remain one action when all targets share the operation's required host and parameters. Include target, exact Runtime operation when known, and dependencies. Do not force JSON or checklist text copying. Explain what each step builds, which requirements it addresses, how operations relate geometrically, prerequisites, and how to verify the result. " + if state.get("modeling_plan_enforced") and str(state.get("modeling_plan_status") or "missing") in {"missing", "revise"} else "" + ) + "Iteratively make a correct model. Do not ask the user for missing dimensions; choose engineering assumptions and record them in requirements.md before it is frozen. " - f"Choose the next coherent batch yourself: each submit_cdsl_fragment may contain 1..{self.settings.agent_max_features_per_fragment} ordered features and must state its batch_goal. " + "Every submit_cdsl_fragment call must contain exactly one feature for the currently loaded operation contract and the active plan action. A semantic step may contain multiple related actions, but submit each action separately with its own plan_action_id and checkpoint. Mixed batches and features arrays are disabled in this run; do not send batch_relationship, shared_revolve_axis, legacy aliases, or historical fields. If the operation changes, read its contract first. " + "Only atomic_id values listed exactly in runtime_operations are supported. Treat generic names such as extrude_add, cylinder_add, or pattern_circular as invalid aliases unless that exact ID appears in runtime_operations; choose a listed operation and read its contract. " + "For sketch operations use exactly {sketch:{workplane,profile},feature:{atomic_id,params}}; for non-sketch operations use {feature:{atomic_id,params,selector_tokens when required}}. Never nest workplane or profile inside feature.params. " "Keep topology-sensitive operations in a later batch unless their selector tokens came from the current checkpoint. Observe and measure before topology-sensitive operations. " - "submit_cdsl_fragment accepts normal JSON text, not a strict provider schema. The server validates the resulting complete CDSL and runs a full CDSL-only rebuild. " - "Never put CDSL object IDs, dependencies, sketch_id, revision IDs, hashes or raw selectors in a fragment. Use only opaque selector tokens from inspect_topology and opaque checkpoint tokens from inspect_model for rollback. " + "submit_cdsl_fragment accepts a structured fragment object, not a nested JSON string. The server validates the resulting complete CDSL and runs a full CDSL-only rebuild. " + "Never put CDSL object IDs, dependencies, sketch_id, revision IDs, hashes, raw selectors, or legacy aliases in a new fragment. Use only opaque selector tokens from inspect_topology and opaque checkpoint tokens from inspect_model for rollback. " "When edge_selector_recovery is present, copy one or more token strings from its tokens list exactly. Do not invent token names, inspect topology again, or omit selector_tokens; the only alternative is rollback_checkpoint. " - "The server exposes only tools that can advance the current state. After every checkpoint, obtain current topology before selecting the next feature. Reuse only the opaque tokens from that immediate result. " + "The server exposes only tools that can advance the current state. After every checkpoint, obtain current topology before selecting the next feature. Reuse only opaque tokens in current_topology_tokens; that complete token bank includes token, kind and compact geometry and is invalid immediately after a checkpoint, rollback, or topology change. " "Every rebuilt batch is rendered and independently reviewed before any checkpoint is created. You cannot approve your own candidate: an accepted review commits it, while a rejected review preserves evidence but leaves the working checkpoint unchanged. You may rollback any ancestor when evidence shows the current approach is wrong. " "Do not claim completion in prose: call complete_task only after checking every requirement. Keep visible prose short and do not expose raw CDSL or selector identifiers." - " When final_repair is present, it is authoritative that the model is incomplete or wrong, so do not call complete_task. Its reported evidence may predate later repair checkpoints: reread current model/CDSL/measurements before relying on exact counts or coordinates. If a submitted candidate is rejected because the solid did not change, or independent coverage reports unresolved requirements, collect at most one inspect_model, one measure_model, and one render_views result for that unchanged geometry. Then call record_geometry_conclusion citing returned evidence_refs. Until that conclusion, submit_cdsl_fragment, rollback_checkpoint, and complete_task are unavailable. After it, follow the chosen decision and do not repeat an already rejected fragment." + " When skip_satisfied_plan_step is exposed, the independent completion ledger has already verified every frozen requirement on the current revision; call it instead of creating redundant geometry. When final_repair is present, it is authoritative that the model is incomplete or wrong, so do not call complete_task. Its reported evidence may predate later repair checkpoints: reread current model/CDSL/measurements before relying on exact counts or coordinates. If a submitted candidate is rejected because the solid did not change, or independent coverage reports unresolved requirements, collect at most one inspect_model, one measure_model, and one render_views result for that unchanged geometry. Then call record_geometry_conclusion and copy evidence_refs exactly from the tool enum. Choose skip only when those current observations prove the active plan step was already satisfied by a prior feature; skip advances to the next step without creating a revision. Until a conclusion, submit_cdsl_fragment, rollback_checkpoint, and complete_task are unavailable. After it, follow the chosen decision and do not repeat an already rejected fragment." ) + if state.get("modeling_plan_enforced") and str(state.get("modeling_plan_status") or "") == "approved": + system += ( + " The approved modeling plan is execution guidance. Generate only the current plan step shown in context, " + "include its plan_step_id and active plan_action_id on submit_cdsl_fragment when available. Keep related actions in the same semantic step, but never combine two independent profiles in one fragment; do not invent or combine unrelated work from another step. " + "The plan is guidance for operation selection, not a request to emit a plan or an IR." + ) messages: list[dict[str, Any]] = [{"role": "system", "content": system}] if not state.get("initial_context_delivered"): messages.extend(item for item in state.get("initial_messages") or [] if isinstance(item, dict)) @@ -1503,7 +3060,7 @@ class AutonomousCdslGenerationRunner: def _inspect_model_payload(self, task_id: str, task: dict[str, Any]) -> dict[str, Any]: cdsl, topology, report, identifier = self._artifact_data(task_id, task) if cdsl is None: - return {"working_head": "root", "bbox_mm": None, "volume": None, "solid_count": 0, "sketches": [], "features": [], "checkpoints": [{"token": "root", "current": True}]} + return {"working_head": "root", "bbox_mm": None, "volume": None, "solid_count": 0, "sketches": [], "features": [], "checkpoints": [{"token": "root", "revision_id": "", "current": True, "rollback_allowed": False, "recommended_for_rollback": False}]} engine_result = report.get("engine_result") if isinstance(report, dict) and isinstance(report.get("engine_result"), dict) else {} geometry = cdsl.get("geometry") if isinstance(cdsl.get("geometry"), dict) else {} summaries = [] @@ -1522,10 +3079,7 @@ class AutonomousCdslGenerationRunner: "sketches": [{"id": item.get("id"), "profile_type": (item.get("profile") or {}).get("type")} for item in geometry.get("sketches") or [] if isinstance(item, dict)], "features": summaries, "topology_record_count": len((topology or {}).get("records") or []), - "checkpoints": [ - {"token": token, "current": revision_id == active_revision} - for token, revision_id in _rollback_tokens(task).items() - ], + "checkpoints": _rollback_token_details(task), } def _head_key(self, task: dict[str, Any]) -> str: @@ -1634,6 +3188,8 @@ class AutonomousCdslGenerationRunner: "status": status, "message": message, "featureIds": candidate.get("feature_ids") or [], + "planStepId": candidate.get("plan_step_id") or "", + "planActionId": candidate.get("plan_action_id") or "", "health": candidate.get("health") or {}, "compatibilityFixes": candidate.get("compatibility_fixes") or [], } @@ -1694,9 +3250,39 @@ class AutonomousCdslGenerationRunner: }) write_json(candidate_dir / "candidate.json", stored) self.store.clear_active_candidate(task_id, candidate_id) - raise AutonomousGenerationError(f"CANDIDATE_REVIEW_FAILED: {error}") from error + detail = str(error) + review_code = "REVIEW_RESPONSE_INCOMPLETE" if "coverage" in detail.lower() or "invalid" in detail.lower() else "REVIEW_SERVICE_ERROR" + raise AutonomousGenerationError(f"CANDIDATE_REVIEW_FAILED: {review_code}: {detail}") from error geometry = cdsl.get("geometry") if isinstance(cdsl.get("geometry"), dict) else {} engine_result = report.get("engine_result") if isinstance(report.get("engine_result"), dict) else {} + plan_step = None + plan_feature_ids: list[str] = [] + if state.get("modeling_plan_enforced"): + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + plan_step_id = str(candidate.get("plan_step_id") or state.get("active_plan_step_id") or "") + plan_step = next((item for item in plan.get("steps") or () if isinstance(item, dict) and str(item.get("step_id") or "") == plan_step_id), None) + plan_feature_ids = [str(item) for item in (plan_step or {}).get("feature_ids") or () if str(item)] + # A final envelope dimension is not expected to exist during the + # initial base step. Keep solid-count validation global, but defer + # dimensions until the plan has advanced beyond its first structural + # step (or until an unplanned/legacy run is being reviewed). + plan_steps = [item for item in (state.get("modeling_plan") or {}).get("steps", []) if isinstance(item, dict)] if state.get("modeling_plan_enforced") else [] + step_index = next((index for index, item in enumerate(plan_steps) if item is plan_step), 0) + check_dimensions = not state.get("modeling_plan_enforced") or step_index > 0 + global_violations = _global_geometry_violations( + candidate.get("health") if isinstance(candidate.get("health"), dict) else {}, + requirements=self.store.read_requirements_document(task_id), + checklist=self._completion_items(task_id), + check_dimensions=check_dimensions, + ) + if global_violations: + stored = read_json(candidate_dir / "candidate.json", candidate) + if isinstance(stored, dict): + stored.update({"status": "blocked_global_invariant", "global_diagnostics": global_violations, "review_failed_at": now_iso()}) + write_json(candidate_dir / "candidate.json", stored) + self.store.clear_active_candidate(task_id, candidate_id) + message = "; ".join(str(item.get("message") or item.get("code") or "") for item in global_violations) + raise AutonomousGenerationError(f"GLOBAL_INVARIANT_VIOLATION: {message}") try: review = await review_candidate_batch( self.settings, @@ -1706,6 +3292,9 @@ class AutonomousCdslGenerationRunner: checklist=self._completion_items(task_id), batch_goal=batch_goal, node_id=candidate_id, + plan_step=plan_step, + plan_feature_ids=plan_feature_ids, + plan_action=(next((item for item in (plan_step or {}).get("actions") or () if isinstance(item, dict) and str(item.get("action_id") or "") == str(candidate.get("plan_action_id") or "")), None) if plan_step else None), deterministic_report={ "health": candidate.get("health") or {}, "engine_result": { @@ -1733,7 +3322,9 @@ class AutonomousCdslGenerationRunner: }) write_json(candidate_dir / "candidate.json", stored) self.store.clear_active_candidate(task_id, candidate_id) - raise AutonomousGenerationError(f"CANDIDATE_REVIEW_FAILED: {error}") from error + detail = str(error) + review_code = "REVIEW_RESPONSE_INCOMPLETE" if "coverage" in detail.lower() or "invalid" in detail.lower() else "REVIEW_SERVICE_ERROR" + raise AutonomousGenerationError(f"CANDIDATE_REVIEW_FAILED: {review_code}: {detail}") from error write_json(review_dir / "candidate-review.json", review) candidate_path = candidate_dir / "candidate.json" stored = read_json(candidate_path, candidate) @@ -1760,6 +3351,15 @@ class AutonomousCdslGenerationRunner: ) -> tuple[dict[str, Any], list[tuple[str, dict[str, Any]]]]: """Promote an already reviewed candidate without another author turn.""" previous_head = self._head_key(task) + candidate_record = read_json(self.store.candidate_dir(task_id, candidate_id) / "candidate.json", {}) + if isinstance(candidate_record, dict): + action_id = str(candidate_record.get("plan_action_id") or "") + if action_id: + candidate_record["completed_plan_actions"] = sorted({ + *[str(key) for key, value in (state.get("plan_action_status") or {}).items() if value in {"complete", "satisfied_by_prior_step"}], + action_id, + }) + write_json(self.store.candidate_dir(task_id, candidate_id) / "candidate.json", candidate_record) built = await asyncio.to_thread( commit_candidate, store=self.store, @@ -1768,9 +3368,16 @@ class AutonomousCdslGenerationRunner: summary="Autonomous CDSL checkpoint", ) state["candidate_action_required"] = {} + self._clear_active_operation_contract(state) self._invalidate_completion_audit(task_id, state, reason="checkpoint_changed") if isinstance(candidate_review, dict): self._apply_candidate_coverage(task_id, state, candidate_review, str(built["revision_id"])) + if isinstance(candidate_record, dict): + self._mark_plan_progress(state, candidate_record) + # Keep the latest accepted checkpoint as the best recoverable state. + # A later repair must not silently discard all valid upstream work by + # choosing root when a usable checkpoint exists. + state["best_known_checkpoint"] = str(built.get("revision_id") or "") final_repair = state.get("final_repair") if isinstance(final_repair, dict) and str(final_repair.get("working_head") or "") == previous_head: final_repair["working_head"] = f"{str(built.get('branch_id') or task.get('active_branch_id') or 'main')}:{str(built.get('revision_id') or '')}" @@ -1860,6 +3467,14 @@ class AutonomousCdslGenerationRunner: source_images=source_images, final_checkpoint=True, ) + if global_violations: + review = { + **review, + "verdict": "repair", + "confidence": 1.0, + "evidence": list(review.get("evidence") or []) + [str(item.get("message") or item.get("code") or "") for item in global_violations], + "global_diagnostics": global_violations, + } write_json(render_dir / "visual-review.json", review) self.store.update_revision_metadata(task_id, active_revision, { "render_manifest_path": (Path("revisions") / active_revision / "review" / "render-manifest.json").as_posix(), @@ -1878,7 +3493,9 @@ class AutonomousCdslGenerationRunner: forced_tool: str | None, ) -> dict[str, Any]: """Call the author while preserving the normal function-call contract.""" - return await self.complete(messages, tools, provider, model, forced_tool) + if self.on_text_delta is None: + return await self.complete(messages, tools, provider, model, forced_tool) + return await self.complete(messages, tools, provider, model, forced_tool, on_text_delta=self.on_text_delta) def _quota_fallback_author( self, @@ -1929,10 +3546,18 @@ class AutonomousCdslGenerationRunner: frozen_attachment_ids=frozen_attachment_ids or [], fresh=False, ) + # Plan review requires an independently configured reviewer. Keep + # legacy/local runs without that reviewer on the original protocol; + # production runs with the feature enabled get the hard plan gate. + state["modeling_plan_enforced"] = bool( + self.settings.modeling_plan_enabled + and self.settings.review_provider_id + and self.settings.review_model_id + ) engine = load_engine(self.settings) requirements = self.store.read_requirements_document(task_id) if requirements: - yield "requirements_document", {"taskId": task_id, "status": "frozen", "path": "requirements.md"} + yield "requirements_document", {"taskId": task_id, "status": "frozen", "path": "requirements.md", "markdown": requirements} self._save_state(task_id, state) try: while True: @@ -2063,10 +3688,30 @@ class AutonomousCdslGenerationRunner: self.store.append_agent_audit(task_id, "tool-arguments-repaired", {"tool": name, "raw_arguments": raw_arguments, "arguments": arguments}) self._record(state, "tool_arguments_repaired", {"tool": name, "message": "Recovered a non-standard tool argument object."}) self.store.append_agent_audit(task_id, "tool-call", {"tool": name, "arguments": arguments}) - yield "tool_call", {"taskId": task_id, "tool": name, "status": "running"} - events, progressed = await self._execute_tool(task_id, request, state, engine, name, arguments) + preview_arguments = { + str(key): (str(value)[:800] + ("..." if len(str(value)) > 800 else "")) + for key, value in arguments.items() + if key not in {"api_key", "authorization", "image", "image_data"} + } + invocation_id = f"{task_id}_tool_{secrets.token_hex(6)}" + yield "tool_call", {"taskId": task_id, "tool": name, "arguments": preview_arguments, "status": "running", "eventId": invocation_id, "invocationId": invocation_id} + try: + events, progressed = await self._execute_tool(task_id, request, state, engine, name, arguments) + except Exception as error: + yield "tool_call", {"taskId": task_id, "tool": name, "status": "error", "message": str(error), "eventId": invocation_id, "invocationId": invocation_id} + raise + completion_event = False + terminal_event = False for event_name, payload in events: - yield event_name, payload + visible_payload = dict(payload) + visible_payload.setdefault("invocationId", invocation_id) + if event_name == "tool_call": + visible_payload.setdefault("eventId", invocation_id) + completion_event = str(visible_payload.get("status") or "").lower() in {"success", "error"} + terminal_event = terminal_event or event_name == "task_terminal" + yield event_name, visible_payload + if not completion_event and not terminal_event: + yield "tool_call", {"taskId": task_id, "tool": name, "status": "success", "message": "工具执行完成。", "eventId": invocation_id, "invocationId": invocation_id} if progressed: state["no_progress"] = 0 else: @@ -2126,7 +3771,7 @@ class AutonomousCdslGenerationRunner: state["completion_checklist_written"] = False state["completion_ledger"] = {} self._record(state, "requirements", {"message": "requirements.md written and frozen", "path": path.name}) - events.append(("requirements_document", {"taskId": task_id, "status": "frozen", "path": "requirements.md"})) + events.append(("requirements_document", {"taskId": task_id, "status": "frozen", "path": "requirements.md", "markdown": str(arguments.get("markdown") or "").strip()})) return events, True if not self.store.read_requirements_document(task_id): raise AutonomousGenerationError("write_requirements_document must be the first tool call") @@ -2150,10 +3795,95 @@ class AutonomousCdslGenerationRunner: "message": "completion.md written and frozen", "item_count": len(items), }) - events.append(("completion_checklist", {"taskId": task_id, "status": "frozen", "itemCount": len(items), "path": path.name})) + events.append(("completion_checklist", {"taskId": task_id, "status": "frozen", "itemCount": len(items), "path": path.name, "markdown": markdown})) return events, True if state.get("completion_checklist_required") and not state.get("completion_checklist_written"): raise AutonomousGenerationError("write_completion_checklist must be completed before modelling") + if name == "write_modeling_plan": + if not state.get("modeling_plan_enforced"): + raise AutonomousGenerationError("PLAN_DISABLED: modeling plan is not enabled for this run") + plan_text = str(arguments.get("plan_text") or "").strip() + current_version = int(state.get("modeling_plan_version") or task.get("modeling_plan_version") or 0) + version = current_version + 1 + runtime_ids = {str(item) for item in getattr(engine, "SUPPORTED_ATOMIC_IDS", ()) if str(item)} + checklist = self._completion_items(task_id) + previous_step_status = deepcopy(state.get("plan_step_status")) if isinstance(state.get("plan_step_status"), dict) else {} + previous_action_status = deepcopy(state.get("plan_action_status")) if isinstance(state.get("plan_action_status"), dict) else {} + previous_feature_status = deepcopy(state.get("plan_feature_status")) if isinstance(state.get("plan_feature_status"), dict) else {} + plan = parse_modeling_plan(plan_text, checklist=checklist, runtime_atomic_ids=runtime_ids) + plan["plan_version"] = version + plan["source_requirements_hash"] = hashlib.sha256( + self.store.read_source_requirements(task_id).encode("utf-8") + ).hexdigest() + plan["plan_source_hash"] = hashlib.sha256(plan_text.encode("utf-8")).hexdigest() + state["modeling_plan"] = plan + state["plan_source_hash"] = plan["plan_source_hash"] + state["modeling_plan_mode"] = str(plan.get("mode") or "semantic_only") + state["modeling_plan_status"] = "pending_review" + self._record(state, "modeling_plan", {"message": "Modeling plan written; independent review is running.", "version": version}) + try: + review = await review_modeling_plan( + self.settings, + source_requirements=self.store.read_source_requirements(task_id), + requirements=self.store.read_requirements_document(task_id), + checklist=checklist, + plan=plan, + runtime_operations=_runtime_summary(engine), + node_id=f"plan-v{version}", + ) + except Exception as error: + state["modeling_plan_status"] = "revise" if current_version else "missing" + raise AutonomousGenerationError(f"PLAN_REVIEW_FAILED: {error}") from error + self.store.write_modeling_plan(task_id, plan_text, version=version) + state["modeling_plan_written"] = True + state["modeling_plan_version"] = version + state["modeling_plan_review_attempts"] = int(state.get("modeling_plan_review_attempts") or 0) + 1 + review["plan_version"] = version + review_path = self.store.write_modeling_plan_review(task_id, review, version=version) + public_review = _public_modeling_plan_review(review) + state["modeling_plan_review_path"] = review_path.as_posix() + if public_review.get("verdict") == "pass": + state["modeling_plan_status"] = "approved" + state["plan_feature_status"] = { + str(item.get("feature_id")): previous_feature_status.get(str(item.get("feature_id")), "pending") + for item in plan.get("features") or () if isinstance(item, dict) + } + state["plan_step_status"] = { + str(item.get("step_id")): previous_step_status.get(str(item.get("step_id")), "pending") + for item in plan.get("steps") or () if isinstance(item, dict) + } + state["plan_action_status"] = { + str(item.get("action_id")): previous_action_status.get(str(item.get("action_id")), "pending") + for item in plan.get("actions") or () if isinstance(item, dict) and str(item.get("action_id") or "") + } + for step in plan.get("steps") or (): + if isinstance(step, dict): + step["status"] = state["plan_step_status"].get(str(step.get("step_id") or ""), "pending") + for action in plan.get("actions") or (): + if isinstance(action, dict): + action["status"] = state["plan_action_status"].get(str(action.get("action_id") or ""), "pending") + next_step = next( + (item for item in plan.get("steps") or () if isinstance(item, dict) and str(item.get("status") or "pending") not in {"complete", "skipped"}), + None, + ) + state["active_plan_step_id"] = str((next_step or {}).get("step_id") or "") + state["active_plan_action_id"] = "" + state["completed_plan_actions"] = [key for key, value in state["plan_action_status"].items() if value in {"complete", "satisfied_by_prior_step"}] + self._initialize_plan_actions(state) + else: + state["modeling_plan_status"] = "revise" + if int(state["modeling_plan_review_attempts"]) >= self.settings.modeling_plan_max_revisions: + raise AutonomousGenerationError("PLAN_REVIEW_REVISE_LIMIT: modeling plan revision limit reached") + self._record(state, "modeling_plan_review", { + "message": f"Modeling plan review returned {public_review.get('verdict')}", + "version": version, + "verdict": public_review.get("verdict"), + "issues": public_review.get("issues") or [], + "path": review_path.as_posix(), + }) + events.append(("modeling_plan", {"taskId": task_id, "version": version, "status": state["modeling_plan_status"], "plan": plan})) + events.append(("modeling_plan_review", {"taskId": task_id, "version": version, "review": public_review})) + return events, True if name == "inspect_model": result = self._inspect_model_payload(task_id, task) evidence_ref = self._record_geometry_diagnostic(task_id, task, state, "inspect") @@ -2232,6 +3962,14 @@ class AutonomousCdslGenerationRunner: # recovery path deliberately exposes a compact, durable list. effective_limit = min(limit, 12) if selector_recovery else limit result = {"working_head": identifier, "tokens": autonomous_candidate_prompt_tokens(tokens, kind=kind)[:effective_limit]} + state["topology_observation"] = { + "working_head": self._head_key(task), + "snapshot_id": str((topology or {}).get("snapshot_id") or ""), + # Keep the compact, author-visible token object rather + # than only its opaque string. Contract reads and other + # tool results may be trimmed from conversation history. + "tokens": deepcopy(result["tokens"]), + } if selector_recovery: if not result["tokens"]: state["edge_selector_recovery"] = {} @@ -2253,6 +3991,30 @@ class AutonomousCdslGenerationRunner: "instruction": "Use one or more returned edge selector tokens in the next chamfer or fillet fragment. Do not inspect topology again until the geometry changes.", } self._record(state, "selector_recovery_complete", {"message": "Fresh edge selector tokens returned; submit a corrected finish or roll back.", "token_count": len(result["tokens"])}) + elif ( + isinstance(action_required, dict) + and str(action_required.get("working_head") or "") == self._head_key(task) + and str(action_required.get("reason") or "") == "topology_selector_recovery" + ): + if result["tokens"]: + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "topology_selector_recovery_submit", + "instruction": "Use only selector tokens returned by this current topology observation in the corrected fragment, or roll back.", + } + self._record(state, "topology_selector_recovery_complete", { + "message": "Fresh current topology tokens returned; submit a corrected selector fragment or roll back.", + "token_count": len(result["tokens"]), + }) + else: + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "topology_selector_recovery_no_tokens", + "instruction": "The active snapshot has no selector tokens of the requested kind. Roll back rather than inventing a selector.", + } + self._record(state, "topology_selector_recovery_no_tokens", { + "message": "No current topology tokens were available for the requested selector recovery.", + }) self._record(state, "inspect_topology", {"message": "Current topology tokens returned", "result": result}) final_repair = state.get("final_repair") if isinstance(final_repair, dict) and str(final_repair.get("working_head") or "") == self._head_key(task): @@ -2265,10 +4027,58 @@ class AutonomousCdslGenerationRunner: atomic_id = str(arguments.get("atomic_id") or "").strip() if not atomic_id: raise AutonomousGenerationError("atomic_id is required for get_cdsl_operation_contract") + previous_contract = self._active_contract(task, state, engine) + repeated_current_contract = str((previous_contract or {}).get("atomic_id") or "") == atomic_id + action_required = state.get("candidate_action_required") + if ( + isinstance(action_required, dict) + and str(action_required.get("working_head") or "") == self._head_key(task) + and str(action_required.get("reason") or "") == "operation_contract_required" + ): + required_ids = [ + str(item) for item in action_required.get("atomic_ids") or () + if isinstance(item, str) and str(item) + ] + if atomic_id not in required_ids: + raise AutonomousGenerationError( + "OPERATION_CONTRACT_REQUIRED: read the required operation contract first: " + + ", ".join(required_ids) + ) result = _operation_contract_payload(engine, atomic_id) - self._record(state, "get_cdsl_operation_contract", {"message": f"Runtime contract returned for {atomic_id}.", "result": result}) + read_ids = [ + str(item) for item in state.setdefault("read_operation_contract_ids", []) + if isinstance(item, str) and str(item) + ] + if atomic_id not in read_ids: + read_ids.append(atomic_id) + state["read_operation_contract_ids"] = read_ids + state["active_operation_contract"] = deepcopy(result) + state["pending_operation"] = atomic_id + state["pending_operation_contract_hash"] = str(result.get("contract_hash") or "") + state["pending_operation_revision"] = str(task.get("active_revision") or "") + if str((state.get("candidate_action_required") or {}).get("reason") or "") == "canonical_format_limit": + state["candidate_action_required"] = {} + if ( + isinstance(action_required, dict) + and str(action_required.get("working_head") or "") == self._head_key(task) + and str(action_required.get("reason") or "") == "operation_contract_required" + ): + required_ids = { + str(item) for item in action_required.get("atomic_ids") or () + if isinstance(item, str) and str(item) + } + if required_ids.issubset(set(read_ids)): + state["candidate_action_required"] = {} + self._record(state, "operation_contract_repeated" if repeated_current_contract else "get_cdsl_operation_contract", { + "message": ( + f"Runtime contract for {atomic_id} was already active on this revision." + if repeated_current_contract else + f"Runtime contract returned for {atomic_id}." + ), + **({} if repeated_current_contract else {"result": result}), + }) events.append(("tool_call", {"taskId": task_id, "tool": name, "status": "success", "message": f"Operation contract loaded for {atomic_id}."})) - return events, True + return events, not repeated_current_contract if name in {"render_views", "render_section"}: _, _, step_path, identifier = _model_paths(self.store, task_id, task) if step_path is None or not step_path.is_file(): @@ -2314,6 +4124,41 @@ class AutonomousCdslGenerationRunner: if evidence_ref: events.append(("geometry_diagnostic", {"taskId": task_id, "kind": "render", "evidenceRef": evidence_ref, "status": "success"})) return events, bool(evidence_ref) or observed + if name == "skip_satisfied_plan_step": + proof = self._current_completion_proof(task, state) + if proof is None: + raise AutonomousGenerationError( + "PLAN_SKIP_INVALID: the frozen completion checklist is not fully verified on the active revision" + ) + if not state.get("modeling_plan_enforced") or str(state.get("modeling_plan_status") or "") != "approved": + raise AutonomousGenerationError("PLAN_SKIP_UNAVAILABLE: skip requires an approved enforced modeling plan") + skipped_step = self._mark_plan_step_skipped( + state, + evidence_refs=[str(proof["evidence_ref"])], + reason=str(arguments.get("reason") or "satisfied by independently verified completion evidence"), + ) + if skipped_step is None: + raise AutonomousGenerationError("PLAN_SKIP_INVALID: no active modeling-plan step can be skipped") + self._clear_active_operation_contract(state) + state["geometry_rejection"] = {} + state["candidate_action_required"] = {} + step_id = str(skipped_step.get("step_id") or "") + self._record(state, "plan_step_satisfied", { + "message": "Skipped a redundant plan step using completion evidence verified on the active revision.", + "plan_step_id": step_id, + "completion_revision": proof["revision_id"], + "completion_item_count": proof["item_count"], + "evidence_ref": proof["evidence_ref"], + }) + events.append(("plan_step_skipped", { + "taskId": task_id, + "planStepId": step_id, + "nextPlanStepId": str(state.get("active_plan_step_id") or ""), + "evidenceRef": proof["evidence_ref"], + "message": "The active revision already satisfies every frozen completion item; the redundant plan step was skipped without creating geometry.", + "status": "success", + })) + return events, True if name == "record_geometry_conclusion": rejection = self._active_noop_rejection(task_id, task, state) if rejection is None: @@ -2329,17 +4174,39 @@ class AutonomousCdslGenerationRunner: valid_refs = {str(item.get("ref") or "") for item in observations.values() if isinstance(item, dict)} unknown = [ref for ref in refs if ref not in valid_refs] if unknown: - raise AutonomousGenerationError("GEOMETRY_CONCLUSION_EVIDENCE_UNKNOWN: " + ", ".join(unknown)) + allowed = ", ".join(sorted(valid_refs)) or "none" + raise AutonomousGenerationError( + "GEOMETRY_CONCLUSION_EVIDENCE_UNKNOWN: " + ", ".join(unknown) + + f"; allowed_evidence_refs: {allowed}" + ) decision = str(arguments.get("decision") or "") plan = arguments.get("optimization_plan") + concluded_plan_step_id = str((self._plan_current_step(state) or {}).get("step_id") or "") if decision == "modify": if not isinstance(plan, dict) or not str(plan.get("action") or plan.get("next_action") or "").strip(): - raise AutonomousGenerationError("GEOMETRY_CONCLUSION_PLAN_REQUIRED: modify requires a non-empty optimization_plan.action (next_action is also accepted)") + raise AutonomousGenerationError( + "GEOMETRY_CONCLUSION_PLAN_REQUIRED: modify requires a non-empty optimization_plan.action " + "(next_action is also accepted); minimal payload: " + "{\"optimization_plan\":{\"action\":\"describe the next materially different modelling step\"}}" + ) # This is audit metadata, not CAD input. Accept the # common next_action spelling used by ordinary tool-call # models while persisting one canonical action for later # author context and human review. plan = {**plan, "action": str(plan.get("action") or plan.get("next_action") or "").strip()} + if decision == "skip": + if not state.get("modeling_plan_enforced"): + raise AutonomousGenerationError("PLAN_SKIP_UNAVAILABLE: skip requires an enforced modeling plan") + if str(arguments.get("root_cause") or "") != "duplicate_feature": + raise AutonomousGenerationError("PLAN_SKIP_INVALID: skip is only valid when evidence shows the step is already satisfied") + skipped_step = self._mark_plan_step_skipped( + state, + evidence_refs=refs, + reason=str((plan or {}).get("reason") or "satisfied by prior geometry"), + ) + if skipped_step is None: + raise AutonomousGenerationError("PLAN_SKIP_INVALID: no active modeling-plan step can be skipped") + concluded_plan_step_id = str(skipped_step.get("step_id") or concluded_plan_step_id) if decision == "complete" and self._completion_has_known_incomplete(task, state): raise AutonomousGenerationError( "GEOMETRY_CONCLUSION_COMPLETE_BLOCKED: the current completion audit has unresolved requirements; choose modify or rollback" @@ -2349,19 +4216,28 @@ class AutonomousCdslGenerationRunner: "evidence_refs": refs, "decision": decision, "optimization_plan": deepcopy(plan) if isinstance(plan, dict) else {}, + "plan_step_id": concluded_plan_step_id, "geometry_fingerprint": fingerprint, "working_head": self._head_key(task), "recorded_at": now_iso(), } diagnosis["conclusion"] = conclusion - state["geometry_rejection"] = {**rejection, "conclusion_required": False, "conclusion_recorded_at": conclusion["recorded_at"]} + if decision == "skip": + # The no-op has been explained by current evidence. Clear + # the gate so authoring resumes at the next plan step. + state["geometry_rejection"] = {} + state["candidate_action_required"] = {} + self._clear_active_operation_contract(state) + else: + state["geometry_rejection"] = {**rejection, "conclusion_required": False, "conclusion_recorded_at": conclusion["recorded_at"]} self._record(state, "geometry_conclusion", { "message": f"Author chose {decision} after unchanged-geometry diagnosis.", "decision": decision, "root_cause": conclusion["root_cause"], "evidence_refs": refs, + "plan_step_id": concluded_plan_step_id, }) - events.append(("geometry_conclusion", {"taskId": task_id, "decision": decision, "rootCause": conclusion["root_cause"], "evidenceRefs": refs, "status": "success"})) + events.append(("geometry_conclusion", {"taskId": task_id, "decision": decision, "rootCause": conclusion["root_cause"], "evidenceRefs": refs, "planStepId": concluded_plan_step_id, "nextPlanStepId": str(state.get("active_plan_step_id") or ""), "status": "success"})) return events, True if name == "submit_cdsl_fragment": if str(task.get("active_candidate_id") or ""): @@ -2370,27 +4246,148 @@ class AutonomousCdslGenerationRunner: batch_goal = str(arguments.get("batch_goal") or "").strip() if not batch_goal: raise AutonomousGenerationError("BATCH_GOAL_REQUIRED: describe the coherent geometry this batch must achieve") + plan_step_id = str(arguments.get("plan_step_id") or "").strip() + plan_action_id = str(arguments.get("plan_action_id") or "").strip() + planned_step: dict[str, Any] | None = None + planned_action: dict[str, Any] | None = None + if state.get("modeling_plan_enforced"): + if str(state.get("modeling_plan_status") or "") != "approved": + raise AutonomousGenerationError("PLAN_REQUIRED: modeling plan must be approved before CDSL generation") + planned_step = self._plan_current_step(state) + expected_step_id = str((planned_step or {}).get("step_id") or state.get("active_plan_step_id") or "") + # A semantic plan may not contain machine-readable step + # identifiers. Default an omitted ID to the active step; + # retain the mismatch guard only for explicit IDs so + # callers cannot accidentally advance a different step. + if not plan_step_id and expected_step_id: + plan_step_id = expected_step_id + if plan_step_id and expected_step_id and plan_step_id != expected_step_id: + raise AutonomousGenerationError(f"PLAN_STEP_MISMATCH: expected active plan step {expected_step_id or 'none'}, received {plan_step_id}") + step_status = state.get("plan_step_status") if isinstance(state.get("plan_step_status"), dict) else {} + missing_prerequisites = [ + item for item in (planned_step or {}).get("prerequisites") or () + if str(item) in step_status and step_status.get(str(item)) != "complete" + ] + if missing_prerequisites: + raise AutonomousGenerationError("PLAN_PREREQUISITE_UNSATISFIED: active step prerequisites are incomplete: " + ", ".join(missing_prerequisites)) + self._initialize_plan_actions(state) + planned_action = self._plan_current_action(state) + expected_action_id = str((planned_action or {}).get("action_id") or state.get("active_plan_action_id") or "") + if not plan_action_id and expected_action_id: + # Compatibility for persisted callers; the dynamic + # enforced schema always requires this field. + plan_action_id = expected_action_id + if not plan_action_id: + raise AutonomousGenerationError("PLAN_ACTION_REQUIRED: active plan action must be provided") + if planned_action is None: + raise AutonomousGenerationError(f"PLAN_ACTION_UNPLANNED: action {plan_action_id} is not part of the active plan step") + if expected_action_id and plan_action_id != expected_action_id: + known = {str(item.get("action_id") or "") for item in (planned_step or {}).get("actions") or () if isinstance(item, dict)} + if plan_action_id in known: + raise AutonomousGenerationError(f"PLAN_ACTION_MISMATCH: expected active plan action {expected_action_id}, received {plan_action_id}") + raise AutonomousGenerationError(f"PLAN_ACTION_UNPLANNED: action {plan_action_id} is not part of the active plan step") + action_status = state.get("plan_action_status") if isinstance(state.get("plan_action_status"), dict) else {} + if action_status.get(plan_action_id) in {"complete", "skipped", "satisfied_by_prior_step"}: + raise AutonomousGenerationError(f"PLAN_ACTION_ALREADY_COMPLETE: action {plan_action_id} is already complete") + action_records = [item for item in (planned_step or {}).get("actions") or () if isinstance(item, dict)] + planned_action = next((item for item in action_records if str(item.get("action_id") or "") == plan_action_id), planned_action) + action_prerequisites = [str(item) for item in (planned_action or {}).get("depends_on") or () if str(item)] + missing_actions = [item for item in action_prerequisites if action_status.get(item) not in {"complete", "skipped", "satisfied_by_prior_step"}] + if missing_actions: + raise AutonomousGenerationError("PLAN_ACTION_PREREQUISITE_UNSATISFIED: action prerequisites are incomplete: " + ", ".join(missing_actions)) + batch_relationship = str(arguments.get("batch_relationship") or "").strip() attempts = state.setdefault("candidate_attempts_by_head", {}) - raw = str(arguments.get("fragment_json") or "") + fragment: dict[str, Any] = {} shared_revolve_axis = arguments.get("shared_revolve_axis") self.store.append_agent_audit(task_id, "fragment", { - "raw_fragment_json": raw, + "fragment": arguments.get("fragment") if isinstance(arguments.get("fragment"), dict) else None, "batch_goal": batch_goal, + "plan_step_id": plan_step_id or None, + "plan_action_id": plan_action_id or None, + "batch_relationship": batch_relationship or None, "shared_revolve_axis": shared_revolve_axis, "working_head": head, }) - try: - fragment = json.loads(raw) - except json.JSONDecodeError as error: - raise AutonomousGenerationError(f"FRAGMENT_JSON_INVALID at line {error.lineno}, column {error.colno}: {error.msg}") from error - if not isinstance(fragment, dict): - raise AutonomousGenerationError("FRAGMENT_JSON_INVALID: fragment_json must decode to an object") - if shared_revolve_axis is not None: - if "revolve_axis" in fragment and fragment["revolve_axis"] != shared_revolve_axis: + fragment, _legacy_fragment = _fragment_argument(arguments) + feature_count = _fragment_feature_count(fragment) + active_contract = self._active_contract(task, state, engine) + if active_contract is None: + requested_operations = _fragment_atomic_ids(fragment) + # Retired persisted callers may still provide the old + # fragment_json string. Keep that restore path alive for + # recovery/tests, while every new structured tool call + # remains contract-first and canonical-only. + if _legacy_fragment and len(requested_operations) == 1 and requested_operations[0] in set(state.get("read_operation_contract_ids") or ()): + expected_atomic_id = requested_operations[0] + active_contract = _operation_contract_payload(engine, expected_atomic_id) + else: + suffix = (" first use of: " + ", ".join(requested_operations)) if requested_operations else "" raise AutonomousGenerationError( - "CONFLICTING_REVOLVE_AXIS: shared_revolve_axis conflicts with fragment_json.revolve_axis" + "OPERATION_CONTRACT_REQUIRED: call get_cdsl_operation_contract before submitting" + suffix + ) + expected_atomic_id = str(active_contract.get("atomic_id") or "") + submitted_atomic_ids = _fragment_atomic_ids(fragment) + if feature_count != 1: + raise AutonomousGenerationError( + "CDSL_SCHEMA_INVALID: canonical autonomous fragments must contain exactly one feature" + ) + if submitted_atomic_ids != [expected_atomic_id]: + received = ", ".join(submitted_atomic_ids) or "none" + raise AutonomousGenerationError( + f"OPERATION_CONTRACT_MISMATCH: expected {expected_atomic_id}, received {received}" + ) + if planned_step is not None: + plan = state.get("modeling_plan") if isinstance(state.get("modeling_plan"), dict) else {} + planned_feature_records = [ + item + for item in plan.get("features") or () + if isinstance(item, dict) and str(item.get("feature_id") or "") in set(planned_step.get("feature_ids") or ()) + ] + planned_features = {str(item.get("operation") or "") for item in planned_feature_records} + submitted_operations = set(_fragment_atomic_ids(fragment)) + # Only enforce operation membership when the plan actually + # contains recognisable Runtime atomic IDs. Natural + # language operation descriptions remain reviewer context. + enforce_operations = bool(planned_features) and all( + operation == "sketch_profile" or operation in getattr(engine, "SUPPORTED_ATOMIC_IDS", ()) + for operation in planned_features + ) + unplanned = sorted(item for item in submitted_operations if item and item not in planned_features) if enforce_operations else [] + if unplanned: + raise AutonomousGenerationError("PLAN_FEATURE_UNPLANNED: fragment operation(s) are outside the active plan step: " + ", ".join(unplanned)) + topology_operations = { + str(item.get("operation") or "") for item in planned_feature_records + if item.get("topology_sensitive") is True + } + if submitted_operations.intersection(topology_operations) and self._current_topology_observation(task_id, task, state) is None: + raise AutonomousGenerationError("TOPOLOGY_SNAPSHOT_OBSERVATION_REQUIRED: the active plan step contains a topology-sensitive feature and requires inspect_topology on the current checkpoint") + planned_operation = str((planned_action or {}).get("operation") or "").strip() + if planned_operation and planned_operation not in {"sketch_profile"} and planned_operation in set(getattr(engine, "SUPPORTED_ATOMIC_IDS", ())): + if expected_atomic_id != planned_operation: + raise AutonomousGenerationError(f"PLAN_ACTION_EXECUTION_SHAPE_INVALID: active action {plan_action_id} requires operation {planned_operation}, received {expected_atomic_id}") + if batch_relationship: + raise AutonomousGenerationError("CDSL_SCHEMA_INVALID: batch_relationship is not allowed for a single canonical operation") + if shared_revolve_axis is not None: + raise AutonomousGenerationError( + "CDSL_SCHEMA_INVALID: shared_revolve_axis is not accepted; put angle_deg and axis in the revolve feature params" + ) + _, topology, _, _ = self._artifact_data(task_id, task) + supplied_tokens = _fragment_selector_tokens(fragment) + if supplied_tokens: + observation = self._current_topology_observation(task_id, task, state, topology=topology) + if observation is None: + raise AutonomousGenerationError( + "TOPOLOGY_SNAPSHOT_OBSERVATION_REQUIRED: selector_tokens require an inspect_topology result from the active snapshot; " + "do not reuse tokens after a checkpoint, rollback, or topology change" + ) + observed_tokens = { + str(item.get("token") or "") for item in observation["tokens"] + if str(item.get("token") or "") + } + if any(token not in observed_tokens for token in supplied_tokens): + raise AutonomousGenerationError( + "TOPOLOGY_TOKEN_UNOBSERVED: selector_tokens must be copied exactly from the current inspect_topology result" ) - fragment["revolve_axis"] = deepcopy(shared_revolve_axis) action_required = state.get("candidate_action_required") recovery = state.get("edge_selector_recovery") if ( @@ -2425,8 +4422,16 @@ class AutonomousCdslGenerationRunner: ) base_path = self.store.current_cdsl_path(task_id) base_cdsl = read_json(base_path) if base_path and base_path.is_file() else None - _, topology, _, _ = self._artifact_data(task_id, task) - materialized, fragment_audit = materialize_autonomous_fragment(base_cdsl if isinstance(base_cdsl, dict) else None, fragment, engine=engine, selector_tokens=autonomous_selector_tokens(topology), max_features=self.settings.agent_max_features_per_fragment) + materialized, fragment_audit = materialize_autonomous_fragment( + base_cdsl if isinstance(base_cdsl, dict) else None, + fragment, + engine=engine, + selector_tokens=autonomous_selector_tokens(topology), + max_features=1, + source="tool_call", + allow_legacy_aliases=bool(_legacy_fragment), + expected_atomic_id=expected_atomic_id, + ) # A materialised document that cannot pass the static CDSL # contract never reached the CAD engine. Treat it as author # format feedback, not one of the scarce rebuild attempts for @@ -2441,11 +4446,22 @@ class AutonomousCdslGenerationRunner: raise AutonomousGenerationError(f"CANDIDATE_ATTEMPT_LIMIT: head {head} exhausted {self.settings.agent_candidate_attempts_per_head} candidate build attempts") attempts[head] = used + 1 candidate = await asyncio.to_thread(build_candidate, settings=self.settings, store=self.store, task_id=task_id, cdsl=materialized, fragment_audit=fragment_audit, parent_revision_id=str(task.get("active_revision") or "")) + candidate["atomic_ids"] = submitted_atomic_ids + if plan_step_id: + candidate["plan_step_id"] = plan_step_id + candidate["plan_action_id"] = plan_action_id or None + candidate["plan_step_goal"] = str((planned_step or {}).get("goal") or "") + candidate["plan_action_title"] = str((planned_action or {}).get("title") or "") + write_json(self.store.candidate_dir(task_id, str(candidate["candidate_id"])) / "candidate.json", candidate) state["format_correction"] = {} state["geometry_rejection"] = {} state["candidate_action_required"] = {} state["edge_selector_recovery"] = {} - self._record(state, "candidate_built", {"message": "Candidate rebuilt successfully; independent visual review is running", "batch_goal": batch_goal, "health": candidate.get("health")}) + self._clear_active_operation_contract(state) + state["topology_observation"] = {} + state["engine_geometry_failure"] = {} + self._clear_active_operation_contract(state) + self._record(state, "candidate_built", {"message": "Candidate rebuilt successfully; independent visual review is running", "batch_goal": batch_goal, "plan_step_id": plan_step_id or None, "health": candidate.get("health")}) fixes = candidate.get("compatibility_fixes") or [] if fixes: self._record(state, "compatibility_normalized", { @@ -2456,6 +4472,8 @@ class AutonomousCdslGenerationRunner: review = await self._review_staged_candidate(task_id, task, state, candidate, batch_goal) state["last_candidate_review"] = { "candidate_id": str(candidate.get("candidate_id") or ""), + "plan_step_id": str(candidate.get("plan_step_id") or ""), + "plan_action_id": str(candidate.get("plan_action_id") or ""), "verdict": review["verdict"], "batch_goal": batch_goal, "batch_goal_status": review["batch_goal_status"], @@ -2466,6 +4484,8 @@ class AutonomousCdslGenerationRunner: self._record(state, "candidate_review", { "message": "; ".join(review["evidence"]) or review["verdict"], "candidate_id": candidate.get("candidate_id"), + "plan_step_id": candidate.get("plan_step_id") or "", + "plan_action_id": candidate.get("plan_action_id") or "", "verdict": review["verdict"], "batch_goal": batch_goal, "batch_goal_status": review["batch_goal_status"], @@ -2648,7 +4668,7 @@ class AutonomousCdslGenerationRunner: requested_token = str(arguments.get("checkpoint_token") or "").strip() requested = _rollback_tokens(task).get(requested_token) if requested is None: - raise AutonomousGenerationError("Rollback target must be a checkpoint token returned by inspect_model") + raise AutonomousGenerationError("ROLLBACK_TARGET_INVALID: rollback_checkpoint requires a token returned by inspect_model with rollback_allowed=true") current = str(task.get("active_revision") or "") by_id = {str(item.get("revision_id") or ""): item for item in task.get("revisions") or [] if isinstance(item, dict)} lineage = {""} @@ -2657,13 +4677,29 @@ class AutonomousCdslGenerationRunner: lineage.add(pointer) pointer = str((by_id.get(pointer) or {}).get("parent_revision_id") or "") if requested not in lineage: - raise AutonomousGenerationError("Rollback target must be an ancestor of the active checkpoint") + raise AutonomousGenerationError("ROLLBACK_TARGET_INVALID: rollback target must be a strict ancestor of the active checkpoint") if requested == current: # Branching at the current checkpoint neither removes a # feature nor changes the geometry. Treating it as a # rollback would let an author erase final-repair state # without repairing the failed requirements. - raise AutonomousGenerationError("Rollback target must be a strict ancestor of the active checkpoint") + raise AutonomousGenerationError("ROLLBACK_TARGET_CURRENT: the current checkpoint cannot be used as a rollback target; choose a strict ancestor, preferably its recommended parent") + best_known = str(state.get("best_known_checkpoint") or "") + reason = str(arguments.get("reason") or "").strip() + root_rollback_justified = any( + marker in reason.casefold() + for marker in ( + "主体几何不可修复", + "主体不可修复", + "base geometry is unrepairable", + "base geometry cannot be repaired", + "unrepairable base geometry", + ) + ) + if not requested and current and best_known and not root_rollback_justified: + raise AutonomousGenerationError( + "ROLLBACK_WOULD_DISCARD_VALID_PROGRESS: root rollback requires an explicit主体几何不可修复 reason" + ) candidate_id = str(task.get("active_candidate_id") or "") if candidate_id: candidate_path = self.store.candidate_dir(task_id, candidate_id) / "candidate.json" @@ -2684,6 +4720,7 @@ class AutonomousCdslGenerationRunner: carried_repair = state.get("final_repair") if isinstance(state.get("final_repair"), dict) else None branch_id = f"branch_{secrets.token_hex(4)}" self.store.rollback_to_revision(task_id, requested, branch_id=branch_id) + self._restore_plan_after_rollback(state, task, requested) self._invalidate_completion_audit(task_id, state, reason="rollback") if carried_repair is not None and str(carried_repair.get("working_head") or "") == self._head_key(task): # A rollback removes an approach, not the unresolved @@ -2697,12 +4734,20 @@ class AutonomousCdslGenerationRunner: state["final_repair"] = carried_repair else: state["final_repair"] = {} - self._record(state, "rollback", {"message": str(arguments.get("reason") or "Rollback requested by author")}) - events.append(("rollback", {"taskId": task_id, "reason": str(arguments.get("reason") or "")})) + self._record(state, "rollback", {"message": reason or "Rollback requested by author", "target_revision": requested or "root"}) + events.append(("rollback", {"taskId": task_id, "reason": reason, "targetRevision": requested or "root"})) return events, True if name == "complete_task": if str(task.get("active_candidate_id") or ""): raise AutonomousGenerationError("An independently reviewed candidate must resolve before final completion") + final_repair = state.get("final_repair") + if isinstance(final_repair, dict) and str(final_repair.get("working_head") or "") == self._head_key(task) and final_repair.get("evidence_stale_after_checkpoint"): + raise AutonomousGenerationError( + "STALE_EVIDENCE: final-repair evidence belongs to an older checkpoint; inspect the current revision before completion" + ) + plan_error = self._plan_gate_error(state) + if plan_error: + raise AutonomousGenerationError(plan_error) coverage_error = self._completion_gate_error(task_id, task, state) if coverage_error: raise AutonomousGenerationError(coverage_error) @@ -2720,6 +4765,9 @@ class AutonomousCdslGenerationRunner: "message": "最终复核未通过。请根据复核证据回滚错误特征或提交修复片段;所有 warning 和 repair 都会阻止发布。", })) return events, True + optional_finish = state.get("optional_finish") if isinstance(state.get("optional_finish"), dict) else None + if optional_finish and isinstance(optional_finish.get("warning"), dict): + self.store.update_task_fields(task_id, {"warnings": [optional_finish["warning"]]}) self.store.finish_generation(task_id, lifecycle="completed") final_task = self.store.read_task(task_id) or {} active_revision = str(final_task.get("published_revision") or final_task.get("active_revision") or "") @@ -2731,6 +4779,12 @@ class AutonomousCdslGenerationRunner: raise AutonomousGenerationError(f"Unknown autonomous authoring tool: {name}") except Exception as error: message = str(error) + if message.startswith("STALE_EVIDENCE:"): + state["last_diagnostic"] = _fragment_diagnostic(message) + self._record(state, "stale_evidence", {"message": message}) + self.store.append_agent_audit(task_id, "tool-diagnostic", state["last_diagnostic"]) + events.append(("tool_call", {"taskId": task_id, "tool": name, "status": "error", "message": _visible_error(message, request), "diagnostic": state["last_diagnostic"]})) + return events, False if name == "submit_cdsl_fragment": if message == "Commit or discard the active candidate before submitting another fragment": state["format_correction"] = {} @@ -2748,15 +4802,88 @@ class AutonomousCdslGenerationRunner: self._record(state, "selector_recovery_token_rejected", { "message": "The finish fragment did not reuse a token from the durable edge selector bank.", }) + elif ( + message.startswith(( + "TOPOLOGY_TOKEN_INVALID:", + "TOPOLOGY_SNAPSHOT_OBSERVATION_REQUIRED:", + "TOPOLOGY_TOKEN_UNOBSERVED:", + )) + or ( + message.startswith("HOLE_FRAGMENT_INVALID:") + and "requires exactly one face selector token" in message + ) + ): + if message.startswith("HOLE_FRAGMENT_INVALID:"): + # Retain every field-level hole correction while the + # action gate obtains a replacement face token. + state["format_correction"] = _format_correction_card( + engine, + fragment=_best_effort_fragment(arguments), + error_message=message, + head=self._head_key(task), + previous=state.get("format_correction") if isinstance(state.get("format_correction"), dict) else None, + ) + else: + state["format_correction"] = {} + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "topology_selector_recovery", + "instruction": "The fragment needs a current face selector token. Call inspect_topology for the active snapshot, then correct every retained field issue and copy only one returned face token into the replacement fragment or roll back.", + } + self._record(state, "topology_selector_recovery_required", { + "message": "A selector fragment must recover its token from the active topology snapshot before retrying.", + "error": message, + }) + elif _is_engine_geometry_failure(message) and str((state.get("active_operation_contract") or {}).get("atomic_id") or "") in {"fillet", "chamfer"}: + # Fillet/chamfer are optional finishing operations. A + # failed BRep edge finish must not invalidate an already + # complete core model or masquerade as a schema error. + state["format_correction"] = {} + state["optional_finish"] = { + "status": "failed", + "atomic_id": str((state.get("active_operation_contract") or {}).get("atomic_id") or ""), + "error": message, + "warning": { + "code": "OPTIONAL_FINISH_SKIPPED", + "message": "Optional fillet/chamfer could not be applied; core geometry may still be completed.", + }, + } + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "optional_finish_failed", + "instruction": "Optional edge finishing failed. Do not retry formatting. Verify the core completion checklist; complete the task if all required items are independently complete, otherwise roll back or continue with a required operation.", + } + if state.get("modeling_plan_enforced"): + optional_step = self._plan_current_step(state) + optional_step_id = str((optional_step or {}).get("step_id") or state.get("active_plan_step_id") or "") + if optional_step_id: + state.setdefault("plan_step_status", {})[optional_step_id] = "skipped" + if isinstance(optional_step, dict): + optional_step["status"] = "skipped" + state["active_plan_step_id"] = "" + self._clear_active_operation_contract(state) + self._record(state, "optional_finish_failed", state["optional_finish"]) + elif _is_engine_geometry_failure(message): + state["format_correction"] = {} + state["engine_geometry_failure"] = { + "working_head": self._head_key(task), + "error": message, + } + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "engine_geometry_invalid", + "instruction": "The CDSL passed authoring validation but produced an invalid BRep or STEP artifact. Diagnose the unchanged checkpoint, then use one isolated replacement operation or roll back; do not treat this as a JSON format correction.", + } + self._record(state, "engine_geometry_invalid", { + "message": "Candidate failed in BRep/STEP execution rather than CDSL authoring validation.", + "error": message, + }) elif message.startswith("CANDIDATE_GEOMETRY_UNCHANGED:"): prior = state.get("geometry_rejection") if isinstance(state.get("geometry_rejection"), dict) else {} signature = f"{self._head_key(task)}|{message}" repeats = int(prior.get("repeat_count") or 0) + 1 if prior.get("signature") == signature else 1 geometry_fingerprint = self._active_geometry_fingerprint(task_id, task) - try: - attempted_fragment = json.loads(str(arguments.get("fragment_json") or "")) - except json.JSONDecodeError: - attempted_fragment = None + attempted_fragment = _best_effort_fragment(arguments) if isinstance(attempted_fragment, dict): fragment_fingerprint = hashlib.sha256( json.dumps(attempted_fragment, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") @@ -2783,15 +4910,39 @@ class AutonomousCdslGenerationRunner: }) elif message.startswith("CANDIDATE_REVIEW_FAILED:"): state["format_correction"] = {} + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "review_service_failure", + "instruction": "The candidate geometry was preserved, but the independent reviewer failed. Do not generate a CDSL repair. Retry review through the service or rollback only.", + "error": message, + } self._record(state, "candidate_review_failed", { "message": "Independent review did not produce a usable verdict; the candidate was retained for audit and the checkpoint was not advanced.", + "reason": "review_service_failure", + }) + self.store.finish_generation(task_id, lifecycle="waiting_review", failure={ + "schema_version": "cad.review-service-failure.v1", + "stage": "candidate_review", + "message": message, + }) + elif message.startswith("GLOBAL_INVARIANT_VIOLATION:"): + state["format_correction"] = {} + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "global_invariant_violation", + "instruction": "The candidate violates a global geometry constraint. Repair the reported invariant before adding unrelated features; do not treat this as a CDSL format correction.", + "error": message, + } + self._record(state, "global_invariant_violation", { + "message": message, + "working_head": self._head_key(task), }) elif message.startswith("CANDIDATE_FRAGMENT_DUPLICATE:"): rejection = self._active_noop_rejection(task_id, task, state) if rejection is not None: fingerprint = str(rejection.get("geometry_fingerprint") or self._active_geometry_fingerprint(task_id, task)) try: - duplicate_fragment = json.loads(str(arguments.get("fragment_json") or "")) + duplicate_fragment = _best_effort_fragment(arguments) duplicate_key = hashlib.sha256( json.dumps(duplicate_fragment, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() @@ -2814,6 +4965,22 @@ class AutonomousCdslGenerationRunner: "message": "Repeated structurally identical rejected fragments now require rollback.", "repeat_count": repeat_count, }) + else: + # A fragment rejected on this geometry is permanently + # blocked until the geometry head changes. Do not turn + # this deterministic duplicate into a generic format + # correction, which used to permit an endless loop. + state["format_correction"] = {} + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "repeated_cdsl_attempt", + "instruction": "This fragment hash was already rejected on the current geometry. Submit a materially different canonical fragment or roll back to a strict ancestor; do not repeat it.", + "diagnostic_code": "REPEATED_CDSL_ATTEMPT", + } + self._record(state, "repeated_cdsl_attempt", { + "message": "A previously rejected fragment was submitted again on unchanged geometry.", + "diagnostic_code": "REPEATED_CDSL_ATTEMPT", + }) elif message.startswith("CANDIDATE_ATTEMPT_LIMIT:"): # This is not a malformed CDSL fragment. Repeating the # same call cannot improve the model, so give the author @@ -2829,12 +4996,139 @@ class AutonomousCdslGenerationRunner: "message": "The current head exhausted its candidate attempts and now requires reassessment or rollback.", "head": self._head_key(task), }) + elif message.startswith("BATCH_RELATIONSHIP_REQUIRED:"): + attempted_fragment = _best_effort_fragment(arguments) or {} + feature_count = _fragment_feature_count(attempted_fragment) + state["format_correction"] = { + "signature": f"{self._head_key(task)}|batch_relationship|{message}", + "repeat_count": 1, + "working_head": self._head_key(task), + "error": message, + "attempted_feature_count": feature_count, + "batch_relationship_required": True, + "instruction": ( + "This fragment has multiple features. Add batch_relationship explaining their exact shared geometry, " + "host, sketch dependency, or ordered boolean result; split unrelated changes into separate candidates." + ), + } + self._record(state, "format_correction", { + "message": "A multi-feature fragment needs an explicit relationship declaration.", + "attempted_feature_count": feature_count, + "error": message, + }) + elif message.startswith("OPERATION_CONTRACT_REQUIRED:"): + raw_ids = message.partition("first use of:")[2] + required_ids = [item.strip() for item in raw_ids.split(",") if item.strip()] + state["format_correction"] = {} + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "operation_contract_required", + "atomic_ids": required_ids, + "instruction": "Read the exact runtime contract for each listed operation before submitting this fragment again.", + } + self._record(state, "operation_contract_required", { + "message": "A new atomic operation must have its runtime contract read before first use.", + "atomic_ids": required_ids, + }) + elif message.startswith("Unsupported runtime atomic_id:"): + unsupported = message.partition(":")[2].strip() + counts = state.setdefault("unsupported_operation_counts", {}) + key = f"{self._head_key(task)}|{unsupported}" + count = int(counts.get(key) or 0) + 1 + counts[key] = count + state["format_correction"] = {} + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "unsupported_operation" if count < 2 else "unsupported_operation_limit", + "unsupported_atomic_id": unsupported, + "instruction": ( + f"The operation {unsupported} is not supported by Runtime. Choose an atomic_id listed exactly in runtime_operations and read that contract." + if count < 2 else + "Repeated unsupported operation selection is blocked. Choose a listed Runtime operation or roll back; do not request this operation again." + ), + } + self._record(state, "unsupported_operation", { + "message": "The requested atomic operation is not supported by Runtime.", + "atomic_id": unsupported, + "repeat_count": count, + }) + elif message.startswith("CDSL_PROFILE_MULTIPLE_OUTERS"): + # Multiple outer loops normally describe independently + # located material targets. Retrying profile syntax cannot + # make a one-feature Runtime operation execute two actions, + # so return to planning without consuming schema retries or + # changing the current checkpoint. + state["format_correction"] = {} + state["modeling_plan_status"] = "revise" + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "plan_action_split_required", + "diagnostic_code": "PLAN_ACTION_GRANULARITY_INVALID", + "plan_step_id": str(state.get("active_plan_step_id") or ""), + "plan_action_id": str(state.get("active_plan_action_id") or ""), + "error": message, + "instruction": ( + "This fragment contains multiple independent outer profiles. Keep the related targets in the same " + "semantic step, revise that step to enumerate one action per target, then submit only the active " + "action as one fragment and one Runtime feature." + ), + } + self._clear_active_operation_contract(state) + self._record(state, "plan_action_split_required", { + "message": "Multiple independent outer profiles require separate actions in the current semantic step.", + "diagnostic_code": "PLAN_ACTION_GRANULARITY_INVALID", + "plan_step_id": str(state.get("active_plan_step_id") or ""), + "plan_action_id": str(state.get("active_plan_action_id") or ""), + "working_head": self._head_key(task), + "candidate_preserved": False, + "checkpoint_preserved": True, + }) + elif message.startswith(("CDSL_SCHEMA_INVALID:", "CDSL_SCHEMA_INVALID at ", "CDSL_CANONICAL_FORMAT_REQUIRED", "CDSL_PROFILE_INVALID", "CDSL_PROFILE_INNER_WITHOUT_OUTER", "CDSL_PROFILE_SELF_INTERSECTING", "OPERATION_CONTRACT_MISMATCH:")): + contract = self._active_contract(task, state) + atomic_id = str((contract or {}).get("atomic_id") or "") + key = f"{self._head_key(task)}|{atomic_id or 'unknown'}" + counts = state.setdefault("schema_retry_counts", {}) + retry_count = int(counts.get(key) or 0) + 1 + counts[key] = retry_count + state["candidate_action_required"] = {} + state["format_correction"] = { + "code": "CDSL_SCHEMA_INVALID" if not message.startswith("CDSL_CANONICAL_FORMAT_REQUIRED") else "CDSL_CANONICAL_FORMAT_REQUIRED", + "signature": f"{key}|{message}", + "repeat_count": retry_count, + "working_head": self._head_key(task), + "atomic_id": atomic_id or None, + "error": message, + "instruction": "Submit exactly one replacement fragment using canonical_fragment_schema. Do not reuse legacy aliases, feature arrays, wrappers, or fields from the invalid fragment.", + "replacement_schema": deepcopy((contract or {}).get("canonical_fragment_schema") or {}), + "canonical_example": deepcopy((contract or {}).get("canonical_fragment_example") or {}), + "retry_policy": {"max_retries": 1, "same_operation_only": True}, + } + if retry_count > 1: + state["format_correction"]["instruction"] = "Canonical schema correction limit reached. Choose a different operation after rereading its contract or rollback; do not repeat this fragment." + state["candidate_action_required"] = { + "working_head": self._head_key(task), + "reason": "canonical_format_limit", + "instruction": "The canonical format retry limit is exhausted. Read a different operation contract or rollback; do not repeat the invalid fragment.", + } + self._record(state, "canonical_schema_error", { + "message": "The submitted fragment did not match the active canonical operation schema.", + "error": message, + "atomic_id": atomic_id, + "retry_count": retry_count, + }) else: + # A new authoring error supersedes a prior rejected + # candidate action on the same head. Leaving + # candidate_review_rejected here masks the exact contract + # correction on the next turn and traps the runner in the + # wrong action branch. + state["candidate_action_required"] = {} correction = _format_correction_card( engine, - fragment_json=str(arguments.get("fragment_json") or ""), + fragment=_best_effort_fragment(arguments), error_message=message, head=self._head_key(task), + shared_revolve_axis=arguments.get("shared_revolve_axis") if isinstance(arguments.get("shared_revolve_axis"), dict) else None, previous=state.get("format_correction") if isinstance(state.get("format_correction"), dict) else None, ) action_required = state.get("candidate_action_required") @@ -2864,9 +5158,23 @@ class AutonomousCdslGenerationRunner: "error": message, }) self.store.append_agent_audit(task_id, "tool-error", {"tool": name, "message": message}) - self._record(state, "tool_error", {"tool": name, "message": message}) + diagnostic = _fragment_diagnostic(message, arguments=arguments) + diagnostic["tool"] = name + if name == "record_geometry_conclusion" and str(diagnostic.get("code") or "").startswith("GEOMETRY_CONCLUSION_EVIDENCE"): + rejection = state.get("geometry_rejection") if isinstance(state.get("geometry_rejection"), dict) else {} + fingerprint = str(rejection.get("geometry_fingerprint") or self._active_geometry_fingerprint(task_id, task)) + diagnosis = ((state.get("geometry_diagnoses_by_fingerprint") or {}).get(fingerprint) or {}) + observations = diagnosis.get("observations") if isinstance(diagnosis, dict) else {} + diagnostic.setdefault("evidence", {})["allowed_evidence_refs"] = sorted({ + str(item.get("ref") or "") + for item in (observations or {}).values() + if isinstance(item, dict) and str(item.get("ref") or "") + }) + state["last_diagnostic"] = diagnostic + self.store.append_agent_audit(task_id, "tool-diagnostic", diagnostic) + self._record(state, "tool_error", {"tool": name, "message": message, "diagnostic": diagnostic}) if name == "submit_cdsl_fragment": - events.append(("candidate_result", {"taskId": task_id, "status": "error", "message": message})) + events.append(("candidate_result", {"taskId": task_id, "status": "error", "message": _visible_error(message, request), "diagnostic": diagnostic})) else: - events.append(("tool_call", {"taskId": task_id, "tool": name, "status": "error", "message": message})) + events.append(("tool_call", {"taskId": task_id, "tool": name, "status": "error", "message": _visible_error(message, request), "diagnostic": diagnostic})) return events, False diff --git a/backend/app/services/cdsl_authoring_schema.py b/backend/app/services/cdsl_authoring_schema.py new file mode 100644 index 00000000..cb32e0c1 --- /dev/null +++ b/backend/app/services/cdsl_authoring_schema.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import hashlib +import json +from copy import deepcopy +from typing import Any + +from jsonschema import Draft202012Validator + +from app.services.engine_service import feature_atomic_contract + + +class CanonicalFragmentError(ValueError): + def __init__(self, message: str, *, path: str = "fragment", code: str = "CDSL_SCHEMA_INVALID") -> None: + super().__init__(message) + self.path = path + self.code = code + + +def _point(size: int) -> dict[str, Any]: + return { + "type": "array", + "items": {"type": "number"}, + "minItems": size, + "maxItems": size, + } + + +def _workplane_schema() -> dict[str, Any]: + point3 = _point(3) + return { + "type": "object", + "properties": { + "origin_mm": deepcopy(point3), + "x_dir": deepcopy(point3), + "normal": deepcopy(point3), + }, + "required": ["origin_mm", "x_dir", "normal"], + "additionalProperties": False, + } + + +def _analytic_segment_schema() -> dict[str, Any]: + point2 = _point(2) + return { + "oneOf": [ + { + "type": "object", + "properties": { + "type": {"const": "line"}, + "start": deepcopy(point2), + "end": deepcopy(point2), + }, + "required": ["type", "start", "end"], + "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, + }, + { + "type": "object", + "properties": { + "type": {"const": "circle"}, + "center": deepcopy(point2), + "radius_mm": {"type": "number", "exclusiveMinimum": 0}, + }, + "required": ["type", "center", "radius_mm"], + "additionalProperties": False, + }, + ] + } + + +def canonical_profile_schema() -> dict[str, Any]: + point2 = _point(2) + segment = _analytic_segment_schema() + contour = { + "type": "object", + "properties": { + "role": {"enum": ["outer", "inner"]}, + "closed": {"const": True}, + "segments": {"type": "array", "minItems": 1, "items": segment}, + }, + "required": ["role", "closed", "segments"], + "additionalProperties": False, + } + return { + "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": 2, "items": contour}, + }, + "required": ["type", "contours"], + "additionalProperties": False, + }, + ] + } + + +def _end_condition_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "type": {"type": "string", "minLength": 1}, + "solidworks_code": {"type": "integer"}, + }, + "required": ["type", "solidworks_code"], + "additionalProperties": False, + } + + +def _axis_schema() -> dict[str, Any]: + point3 = _point(3) + return { + "type": "object", + "properties": {"origin_mm": deepcopy(point3), "direction": deepcopy(point3)}, + "required": ["origin_mm", "direction"], + "additionalProperties": False, + } + + +def _hole_positions_schema() -> dict[str, Any]: + return { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": {"mm": _point(3)}, + "required": ["mm"], + "additionalProperties": False, + }, + } + + +def _parameter_schema(atomic_id: str, contract: dict[str, Any]) -> dict[str, Any]: + positive = {"type": "number", "exclusiveMinimum": 0} + number = {"type": "number"} + integer = {"type": "integer", "minimum": 1} + point3 = _point(3) + properties: dict[str, Any] = {} + required = [name for name in contract["required_params"] if name not in {"host_face", "source_feature_ids", "mirror_plane"}] + + if atomic_id.startswith("extrude_"): + properties = { + "distance_mm": deepcopy(number), + "reverse_distance_mm": deepcopy(number), + "reverse": {"type": "boolean"}, + "end_condition": _end_condition_schema(), + "reverse_end_condition": _end_condition_schema(), + } + elif atomic_id.startswith("revolve_"): + properties = { + "angle_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 360}, + "axis": _axis_schema(), + "reverse": {"type": "boolean"}, + } + elif atomic_id.startswith("hole_"): + properties = { + "diameter_mm": deepcopy(positive), + "depth_mm": deepcopy(positive), + "positions": _hole_positions_schema(), + "drill_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793}, + "countersink_diameter_mm": deepcopy(positive), + "countersink_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793}, + "counterbore_diameter_mm": deepcopy(positive), + "counterbore_depth_mm": deepcopy(positive), + } + elif atomic_id == "sphere_add": + properties = {"radius_mm": deepcopy(positive), "center_mm": deepcopy(point3)} + elif atomic_id == "fillet": + properties = {"radius_mm": deepcopy(positive), "tangent_propagation": {"type": "boolean"}} + elif atomic_id == "chamfer": + properties = { + "distance_mm": deepcopy(positive), + "distance_2_mm": deepcopy(positive), + "angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793}, + } + elif atomic_id == "pattern_linear": + properties = { + "direction_1": deepcopy(point3), + "spacing_1_mm": deepcopy(positive), + "pattern_count_1": deepcopy(integer), + "direction_2": deepcopy(point3), + "spacing_2_mm": deepcopy(positive), + "pattern_count_2": deepcopy(integer), + } + elif atomic_id == "pattern_mirror": + properties = {} + elif atomic_id == "reference_axis": + properties = {"axis": _axis_schema()} + elif atomic_id == "reference_plane": + properties = {"plane": _workplane_schema()} + elif atomic_id == "hole_wizard": + required = ["hole_type", "diameter_mm", "depth_mm"] + properties = { + "hole_type": {"type": "string", "minLength": 1}, + "diameter_mm": deepcopy(positive), + "depth_mm": deepcopy(positive), + "positions": _hole_positions_schema(), + "thread": {"type": "object"}, + "countersink": {"type": "object"}, + "counterbore": {"type": "object"}, + } + else: + for name in [*contract["required_params"], *contract["optional_params"]]: + if name not in {"host_face", "source_feature_ids", "mirror_plane"}: + properties[name] = {} + + allowed = set(required) | { + name for name in contract["optional_params"] + if name not in {"host_face", "source_feature_ids", "mirror_plane"} + } + properties = {name: value for name, value in properties.items() if name in allowed} + return { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + } + + +def _selector_limits(atomic_id: str, contract: dict[str, Any]) -> tuple[int, int] | None: + if atomic_id.startswith("hole_") or atomic_id == "hole_wizard": + return 1, 1 + slot = contract.get("selector_slot") + if not isinstance(slot, dict): + return None + return int(slot.get("min_items") or 1), int(slot.get("max_items") or 1) + + +def build_operation_fragment_schema( + engine: Any, + atomic_id: str, + *, + correction: dict[str, Any] | None = None, + allow_multi_feature: bool = False, +) -> dict[str, Any]: + del correction + if allow_multi_feature: + raise ValueError("Canonical autonomous authoring currently permits exactly one feature") + contract = feature_atomic_contract(engine, atomic_id) + feature_properties: dict[str, Any] = { + "atomic_id": {"const": atomic_id}, + "params": _parameter_schema(atomic_id, contract), + } + feature_required = ["atomic_id", "params"] + selector_limits = _selector_limits(atomic_id, contract) + if selector_limits is not None: + minimum, maximum = selector_limits + feature_properties["selector_tokens"] = { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "minItems": minimum, + "maxItems": maximum, + "uniqueItems": True, + } + feature_required.append("selector_tokens") + feature_schema = { + "type": "object", + "properties": feature_properties, + "required": feature_required, + "additionalProperties": False, + } + properties: dict[str, Any] = {"feature": feature_schema} + required = ["feature"] + if contract["requires_sketch"]: + properties["sketch"] = { + "type": "object", + "properties": {"workplane": _workplane_schema(), "profile": canonical_profile_schema()}, + "required": ["workplane", "profile"], + "additionalProperties": False, + } + required.insert(0, "sketch") + return { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + } + + +def canonical_fragment_example(engine: Any, atomic_id: str) -> dict[str, Any]: + contract = feature_atomic_contract(engine, atomic_id) + feature: dict[str, Any] = {"atomic_id": atomic_id, "params": {}} + params = feature["params"] + if atomic_id.startswith("extrude_"): + params["distance_mm"] = 10 + if "reverse_distance_mm" in contract["required_params"]: + params["reverse_distance_mm"] = 5 + elif atomic_id.startswith("revolve_"): + params.update({"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}) + elif atomic_id.startswith("hole_"): + params.update({"diameter_mm": 10, "depth_mm": 20, "positions": [{"mm": [0, 0, 0]}]}) + if atomic_id == "hole_countersink": + params.update({"countersink_diameter_mm": 16, "countersink_angle_rad": 1.5707963267948966}) + if atomic_id == "hole_counterbore": + params.update({"counterbore_diameter_mm": 16, "counterbore_depth_mm": 4}) + feature["selector_tokens"] = ["face-token-from-current-topology"] + elif atomic_id == "hole_wizard": + params.update({"hole_type": "simple", "diameter_mm": 10, "depth_mm": 20}) + feature["selector_tokens"] = ["face-token-from-current-topology"] + elif atomic_id == "sphere_add": + params.update({"radius_mm": 10, "center_mm": [0, 0, 0]}) + elif atomic_id == "fillet": + params["radius_mm"] = 0.5 + feature["selector_tokens"] = ["edge-token-from-current-topology"] + elif atomic_id == "chamfer": + params["distance_mm"] = 0.5 + feature["selector_tokens"] = ["edge-token-from-current-topology"] + elif atomic_id == "pattern_linear": + params.update({"direction_1": [1, 0, 0], "spacing_1_mm": 10, "pattern_count_1": 2}) + elif atomic_id == "pattern_mirror": + feature["selector_tokens"] = ["plane-token-from-current-topology"] + elif atomic_id == "reference_axis": + params["axis"] = {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]} + elif atomic_id == "reference_plane": + params["plane"] = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} + + fragment: dict[str, Any] = {"feature": feature} + if contract["requires_sketch"]: + fragment["sketch"] = { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]}, + } + fragment = {"sketch": fragment["sketch"], "feature": feature} + return fragment + + +def operation_contract_hash(atomic_id: str, schema: dict[str, Any]) -> str: + encoded = json.dumps({"atomic_id": atomic_id, "schema": schema}, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def validate_canonical_fragment(engine: Any, fragment: dict[str, Any], *, expected_atomic_id: str = "") -> str: + feature = fragment.get("feature") if isinstance(fragment, dict) else None + atomic_id = str((feature or {}).get("atomic_id") or "") if isinstance(feature, dict) else "" + if expected_atomic_id and atomic_id and atomic_id != expected_atomic_id: + raise CanonicalFragmentError( + f"OPERATION_CONTRACT_MISMATCH: expected {expected_atomic_id}, received {atomic_id}", + path="fragment.feature.atomic_id", + code="OPERATION_CONTRACT_MISMATCH", + ) + if not atomic_id: + raise CanonicalFragmentError( + "CDSL_SCHEMA_INVALID: canonical fragment requires fragment.feature.atomic_id", + path="fragment.feature.atomic_id", + ) + schema = build_operation_fragment_schema(engine, atomic_id) + errors = sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda error: (list(error.absolute_path), error.message)) + if errors: + error = errors[0] + path = "fragment" + "".join( + f"[{item}]" if isinstance(item, int) else f".{item}" + for item in error.absolute_path + ) + raise CanonicalFragmentError(f"CDSL_SCHEMA_INVALID at {path}: {error.message}", path=path) + profile = ((fragment.get("sketch") or {}).get("profile") or {}) if isinstance(fragment, dict) else {} + if isinstance(profile, dict) and profile.get("type") == "analytic_contours": + roles = [str(item.get("role") or "") for item in profile.get("contours") or () if isinstance(item, dict)] + if roles.count("outer") != 1 or roles.count("inner") > 1: + raise CanonicalFragmentError( + "CDSL_PROFILE_MULTIPLE_OUTERS: analytic_contours requires exactly one outer contour and at most one inner contour", + path="fragment.sketch.profile.contours", + code="CDSL_PROFILE_MULTIPLE_OUTERS", + ) + if roles.count("outer") == 0 and roles.count("inner"): + raise CanonicalFragmentError( + "CDSL_PROFILE_INNER_WITHOUT_OUTER: analytic_contours cannot contain an inner contour without an outer contour", + path="fragment.sketch.profile.contours", + code="CDSL_PROFILE_INNER_WITHOUT_OUTER", + ) + return atomic_id diff --git a/backend/app/services/cdsl_fragment.py b/backend/app/services/cdsl_fragment.py index 6f1a6646..58965721 100644 --- a/backend/app/services/cdsl_fragment.py +++ b/backend/app/services/cdsl_fragment.py @@ -5,6 +5,7 @@ from __future__ import annotations from copy import deepcopy from hashlib import sha256 import json +import math from typing import Any @@ -231,12 +232,15 @@ def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[ def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None: - """Accept the common one-feature/one-sketch nesting without choosing geometry. + """Accept common feature-local sketch spellings without choosing geometry. The public fragment grammar owns one ordered sketch list and one ordered - feature list. Some tool-call models naturally nest each sketch below its - feature. That representation is losslessly transformable only when every - feature supplies exactly one sketch and no root sketch collection exists. + feature list. Tool-call models commonly emit either a direct feature with + a local ``sketch`` or a wrapper shaped as ``{sketch, feature}``. Both are + losslessly transformable when the fragment has no root sketch collection. + Sketchless features such as ``sphere_add`` and ``chamfer`` may be mixed in + the same batch; the materializer pairs only sketch-requiring features with + the lifted sketches. """ if any(key in fragment for key in ("sketch", "sketches", "add_sketches")): return @@ -244,21 +248,225 @@ def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[s if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features): return lifted: list[dict[str, Any]] = [] - for index, feature in enumerate(features): - nested = feature.get("sketches", feature.get("sketch")) + normalized_features: list[dict[str, Any]] = [] + for index, item in enumerate(features): + wrapped = item.get("feature") + if wrapped is not None: + if not isinstance(wrapped, dict): + return + feature = deepcopy(wrapped) + if "selector_tokens" in item: + if "selector_tokens" in feature: + raise AutonomousFragmentError( + f"features[{index}] supplies selector_tokens both on the wrapper and feature" + ) + feature["selector_tokens"] = deepcopy(item["selector_tokens"]) + nested = item.get("sketches", item.get("sketch")) + fixes.append({"path": f"features[{index}]", "from": "{sketch,feature}", "to": "feature", "action": "unwrapped_equivalent"}) + else: + feature = deepcopy(item) + nested = feature.get("sketches", feature.get("sketch")) if isinstance(nested, dict): sketches = [nested] elif isinstance(nested, list): sketches = nested else: - return + normalized_features.append(feature) + continue if len(sketches) != 1 or not isinstance(sketches[0], dict): return feature.pop("sketch", None) feature.pop("sketches", None) lifted.append(sketches[0]) + normalized_features.append(feature) fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"}) - fragment["sketches"] = lifted + fragment["features"] = normalized_features + if lifted: + fragment["sketches"] = lifted + + +def _lift_param_embedded_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None: + """Lift an exact legacy ``params.workplane/profile`` sketch spelling. + + Some authors place an extrusion's complete sketch inside its params object. + The workplane and profile retain their meaning verbatim, so extracting them + is safe. Partial shapes remain invalid instead of being guessed. + """ + if any(key in fragment for key in ("sketch", "sketches", "add_sketches")): + return + features = fragment.get("features", fragment.get("add_features")) + if not isinstance(features, list) or not all(isinstance(item, dict) for item in features): + return + lifted: list[dict[str, Any]] = [] + for index, feature in enumerate(features): + atomic_id = str(feature.get("atomic_id") or "") + params = feature.get("params") + if not atomic_id.startswith(("extrude_", "revolve_")) or not isinstance(params, dict): + continue + workplane = params.get("workplane") + profile = params.get("profile") + if workplane is None and profile is None: + continue + if not isinstance(workplane, dict) or not isinstance(profile, dict): + return + params.pop("workplane") + params.pop("profile") + lifted.append({"workplane": workplane, "profile": profile}) + fixes.append({"path": f"features[{index}].params", "from": "workplane/profile", "to": "sketches[]", "action": "lifted_equivalent"}) + if lifted: + fragment["sketches"] = lifted + + +def _set_reverse_from_direction(params: dict[str, Any], *, reverse: bool, location: str, fixes: list[dict[str, str]]) -> None: + if "reverse" in params and params["reverse"] is not reverse: + raise AutonomousFragmentError( + f"CONFLICTING_PARAMETER_ALIASES at {location}: direction conflicts with reverse" + ) + params["reverse"] = reverse + params.pop("direction", None) + fixes.append({"path": location, "from": "direction", "to": "reverse", "action": "normalized_equivalent"}) + + +def _normalize_extrude_direction( + params: dict[str, Any], + sketch: dict[str, Any] | None, + *, + location: str, + fixes: list[dict[str, str]], +) -> None: + """Accept an extrusion direction only when it exactly restates the sketch.""" + direction = params.get("direction") + if direction is None: + return + if isinstance(direction, str): + normalized = direction.strip().lower() + if normalized in {"negative", "reverse", "-normal"}: + _set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes) + elif normalized in {"positive", "forward", "+normal"}: + _set_reverse_from_direction(params, reverse=False, location=location, fixes=fixes) + return + workplane = sketch.get("workplane") if isinstance(sketch, dict) else None + normal = workplane.get("normal") if isinstance(workplane, dict) else None + if ( + not isinstance(direction, list) + or not isinstance(normal, list) + or len(direction) != 3 + or len(normal) != 3 + or not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in [*direction, *normal]) + ): + return + direction_norm = math.sqrt(sum(float(value) ** 2 for value in direction)) + normal_norm = math.sqrt(sum(float(value) ** 2 for value in normal)) + if direction_norm == 0 or normal_norm == 0: + return + cosine = sum(float(direction[index]) * float(normal[index]) for index in range(3)) / (direction_norm * normal_norm) + if math.isclose(cosine, 1.0, abs_tol=1e-9): + params.pop("direction") + fixes.append({"path": location, "from": "direction", "to": "workplane.normal", "action": "deduplicated_equivalent"}) + elif math.isclose(cosine, -1.0, abs_tol=1e-9): + _set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes) + + +def _normalize_angle_radians(params: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None: + """Convert the explicitly unit-labelled angle_rad alias to angle_deg.""" + if "angle_rad" not in params: + return + radians = params["angle_rad"] + if not isinstance(radians, (int, float)) or isinstance(radians, bool) or not math.isfinite(float(radians)): + return + degrees = float(radians) * 180.0 / math.pi + if "angle_deg" in params: + supplied = params["angle_deg"] + if not isinstance(supplied, (int, float)) or isinstance(supplied, bool) or not math.isclose(float(supplied), degrees, rel_tol=0.0, abs_tol=1e-9): + raise AutonomousFragmentError( + f"CONFLICTING_PARAMETER_ALIASES at {location}: angle_rad conflicts with angle_deg" + ) + params.pop("angle_rad") + fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "deduplicated_equivalent"}) + return + params["angle_deg"] = degrees + params.pop("angle_rad") + fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "converted_unit"}) + + +def _normalize_concentric_circle_contours(profile: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None: + """Expand a common two-circle annulus shorthand into analytic contours.""" + if profile.get("type") != "analytic_contours": + return + contours = profile.get("contours") + if not isinstance(contours, list) or len(contours) != 2 or not all(isinstance(item, dict) for item in contours): + return + if not all(item.get("type") == "circle" and isinstance(item.get("center"), list) and len(item["center"]) == 2 for item in contours): + return + if contours[0]["center"] != contours[1]["center"]: + return + try: + ordered = sorted(contours, key=lambda item: float(item["radius_mm"]), reverse=True) + except (KeyError, TypeError, ValueError): + return + if float(ordered[0]["radius_mm"]) <= float(ordered[1]["radius_mm"]): + return + profile["contours"] = [ + {"role": role, "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]} + for role, item in zip(("outer", "inner"), ordered) + ] + fixes.append({"path": location, "from": "circle contour shorthand", "to": "analytic_contours.segments", "action": "expanded_equivalent"}) + + +def _finite_vector3(value: Any) -> tuple[float, float, float] | None: + """Return a finite numeric vector when the author supplied one.""" + if ( + not isinstance(value, list) + or len(value) != 3 + or not all(isinstance(component, (int, float)) and not isinstance(component, bool) for component in value) + ): + return None + result = tuple(float(component) for component in value) + return result if all(math.isfinite(component) for component in result) else None + + +def _validate_revolve_axis_in_sketch_plane( + atomic_id: str, + params: dict[str, Any], + sketch: dict[str, Any], +) -> None: + """Reject a revolve axis that cannot be a construction line of its sketch. + + A solid revolve is defined around an axis in the source sketch plane. + Letting an out-of-plane axis reach OCC can produce degenerate BReps that + fail much later during tessellation, so enforce this geometric invariant + before candidate staging. Malformed vectors are left to CDSL schema + validation, which can report their field-level shape. + """ + axis = params.get("axis") + workplane = sketch.get("workplane") if isinstance(sketch.get("workplane"), dict) else None + if not isinstance(axis, dict) or not isinstance(workplane, dict): + return + axis_origin = _finite_vector3(axis.get("origin_mm")) + axis_direction = _finite_vector3(axis.get("direction")) + plane_origin = _finite_vector3(workplane.get("origin_mm")) + plane_normal = _finite_vector3(workplane.get("normal")) + if None in {axis_origin, axis_direction, plane_origin, plane_normal}: + return + assert axis_origin is not None and axis_direction is not None and plane_origin is not None and plane_normal is not None + direction_length = math.sqrt(sum(component * component for component in axis_direction)) + normal_length = math.sqrt(sum(component * component for component in plane_normal)) + if direction_length == 0 or normal_length == 0: + return + direction_normal_dot = abs(sum(axis_direction[index] * plane_normal[index] for index in range(3)) / (direction_length * normal_length)) + if direction_normal_dot > 1e-7: + raise AutonomousFragmentError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: " + f"{atomic_id} params.axis.direction must be parallel to sketch.workplane; " + f"abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" + ) + origin_plane_offset = abs(sum((axis_origin[index] - plane_origin[index]) * plane_normal[index] for index in range(3)) / normal_length) + if origin_plane_offset > 1e-6: + raise AutonomousFragmentError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: " + f"{atomic_id} params.axis.origin_mm must lie in sketch.workplane; " + f"plane_offset_mm={origin_plane_offset:.3g}" + ) def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]: @@ -275,6 +483,7 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A normalized = deepcopy(fragment) fixes: list[dict[str, str]] = [] _lift_feature_local_sketches(normalized, fixes=fixes) + _lift_param_embedded_sketches(normalized, fixes=fixes) feature_values: list[tuple[dict[str, Any], str]] = [] feature = normalized.get("feature") if isinstance(feature, dict): @@ -310,6 +519,7 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A location=f"{location}.params", fixes=fixes, ) + _normalize_angle_radians(params, location=f"{location}.params", fixes=fixes) axis = params.get("axis") if axis is None: axis = {} @@ -349,10 +559,41 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A sketches = normalized.get("sketches", normalized.get("add_sketches")) if isinstance(sketches, list): sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict)) + sketch_feature_index = 0 + for current_feature, feature_location in feature_values: + atomic_id = str(current_feature.get("atomic_id") or "") + if not atomic_id.startswith(("extrude_", "revolve_")): + continue + current_sketch = sketch_values[sketch_feature_index][0] if sketch_feature_index < len(sketch_values) else None + sketch_feature_index += 1 + if atomic_id.startswith("extrude_"): + params = current_feature.get("params") + if isinstance(params, dict): + _normalize_extrude_direction( + params, + sketch=current_sketch, + location=f"{feature_location}.params", + fixes=fixes, + ) for current_sketch, location in sketch_values: profile = current_sketch.get("profile") if isinstance(profile, dict) and profile.get("type") == "polygon": _move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes) + if isinstance(profile, dict): + _normalize_concentric_circle_contours(profile, location=f"{location}.profile", fixes=fixes) + # Earlier versions advertised sphere_add as sketch-backed even though its + # executor has always used only radius_mm and center_mm. Preserve that + # single-feature spelling without keeping an unused locator sketch in the + # immutable CDSL document. + if ( + len(feature_values) == 1 + and str(feature_values[0][0].get("atomic_id") or "") == "sphere_add" + and len(sketch_values) == 1 + ): + normalized.pop("sketch", None) + normalized.pop("sketches", None) + normalized.pop("add_sketches", None) + fixes.append({"path": "sketch", "from": "sphere locator sketch", "to": "none", "action": "dropped_unused_legacy_locator"}) return normalized, fixes @@ -363,6 +604,9 @@ def materialize_autonomous_fragment( engine: Any, selector_tokens: dict[str, dict[str, Any]], max_features: int, + source: str = "legacy_restore", + allow_legacy_aliases: bool = True, + expected_atomic_id: str = "", ) -> tuple[dict[str, Any], dict[str, Any]]: """Append authored geometry while assigning only server-owned metadata. @@ -374,7 +618,25 @@ def materialize_autonomous_fragment( # becoming an import cycle. from app.services.engine_service import feature_atomic_contract + from app.services.cdsl_authoring_schema import CanonicalFragmentError, validate_canonical_fragment + normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment) + if not allow_legacy_aliases: + if compatibility_fixes: + first = compatibility_fixes[0] + location = str(first.get("path") or "fragment") + legacy = str(first.get("from") or "legacy field") + canonical = str(first.get("to") or "canonical field") + raise AutonomousFragmentError( + f"CDSL_CANONICAL_FORMAT_REQUIRED at fragment.{location}: " + f"{legacy} is a legacy spelling; use {canonical}" + ) + try: + validate_canonical_fragment(engine, fragment, expected_atomic_id=expected_atomic_id) + except CanonicalFragmentError as error: + raise AutonomousFragmentError(str(error)) from error + normalized_fragment = deepcopy(fragment) + compatibility_fixes = [] sketches, features = _fragment_lists(normalized_fragment) if len(features) > max_features: raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)") @@ -388,7 +650,12 @@ def materialize_autonomous_fragment( materialized_sketches: list[dict[str, Any]] = [] materialized_features: list[dict[str, Any]] = [] - for index, source_feature in enumerate(features): + sketch_index = 0 + for source_feature in features: + # Materialization injects server-owned ids, host faces and pattern + # sources. Work on a private copy so the original tool-call fragment + # remains intact in audit records and diagnostic replacement cards. + source_feature = deepcopy(source_feature) forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature) if forbidden: raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden))) @@ -399,31 +666,68 @@ def materialize_autonomous_fragment( params = source_feature.get("params") if not isinstance(params, dict): raise AutonomousFragmentError("Each fragment feature must contain a params object") - if (atomic_id.startswith("hole_") or atomic_id == "hole_wizard") and "host_face" in params: + if atomic_id.startswith("revolve_") and params.get("angle_deg") is None: + # A shared batch axis is deliberately limited to the axis. A + # default revolution angle would silently turn valid partial + # revolves into a different solid, so it remains author-owned. raise AutonomousFragmentError( - f"{atomic_id} host_face is server-owned: put exactly one face token in selector_tokens and use " - "positions as [{\"mm\":[x_mm,y_mm,z_mm]}], not raw host_face or bare coordinate arrays" + f"{atomic_id} requires params.angle_deg; revolve_axis (including shared_revolve_axis) " + "supplies only params.axis. Declare an explicit angle in degrees." ) + token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard" tokens = source_feature.pop("selector_tokens", []) - if not isinstance(tokens, list) or not all(isinstance(token, str) for token in tokens) or len(set(tokens)) != len(tokens): + token_list_is_valid = ( + isinstance(tokens, list) + and all(isinstance(token, str) for token in tokens) + and len(set(tokens)) == len(tokens) + ) + if not token_list_is_valid and not token_backed_param: raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens") + if not token_list_is_valid: + tokens = [] selected: list[dict[str, Any]] = [] + invalid_tokens: list[str] = [] for token in tokens: candidate = selector_tokens.get(token) if candidate is None: + if token_backed_param: + invalid_tokens.append(token) + continue raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot") selected.append(deepcopy(candidate["selector"])) slot = contract.get("selector_slot") - token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard" + if token_backed_param: + # Report all author-correctable hole errors at once. A hole is + # topology-sensitive, so its host face remains server-owned and + # must be injected from one current face token. + issues: list[str] = [] + author_params = (set(contract["required_params"]) | set(contract["optional_params"])) - {"host_face"} + if "host_face" in params: + issues.append("params.host_face is server-owned; use selector_tokens") + missing = [name for name in contract["required_params"] if name != "host_face" and name not in params] + if missing: + issues.append("missing params: " + ", ".join(missing)) + unexpected = sorted(name for name in params if name not in author_params and name != "host_face") + if unexpected: + issues.append("unsupported params: " + ", ".join(unexpected)) + if not token_list_is_valid: + issues.append("selector_tokens must be a unique array of opaque tokens") + if invalid_tokens: + issues.append("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot") + if len(tokens) != 1 or len(selected) != 1 or str((selected[0] if selected else {}).get("kind") or "") != "face": + issues.append(f"{atomic_id} requires exactly one face selector token for its host face") + if issues: + raise AutonomousFragmentError("HOLE_FRAGMENT_INVALID: " + "; ".join(issues)) if not slot and tokens and not token_backed_param: raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens") if isinstance(slot, dict): minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0) if not minimum <= len(selected) <= maximum: raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)") - elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard": - if len(selected) != 1 or str(selected[0].get("kind") or "") != "face": - raise AutonomousFragmentError(f"{atomic_id} requires exactly one face selector token for its host face") + elif token_backed_param: + # Hole token validation above deliberately aggregates every + # actionable error before this materialization boundary. + pass elif atomic_id.startswith("revolve_"): axis = params.get("axis") if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis: @@ -437,17 +741,18 @@ def materialize_autonomous_fragment( output["id"] = feature_id output["depends_on"] = [last_feature_id] if last_feature_id else [] if contract["requires_sketch"]: - if index >= len(sketches): + if sketch_index >= len(sketches): raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment") - sketch = sketches[index] + sketch = sketches[sketch_index] + sketch_index += 1 if "id" in sketch or "attachment" in sketch or "profile_from" in sketch: raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned") sketch_id = _autonomous_id("sketch", used_sketch_ids) sketch["id"] = sketch_id + if atomic_id.startswith("revolve_"): + _validate_revolve_axis_in_sketch_plane(atomic_id, params, sketch) materialized_sketches.append(sketch) output["sketch_id"] = sketch_id - elif index < len(sketches): - raise AutonomousFragmentError(f"{atomic_id} does not accept a sketch") if atomic_id.startswith("pattern_") and "source_feature_ids" not in params: if not last_feature_id: raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature") @@ -467,7 +772,7 @@ def materialize_autonomous_fragment( output["selectors"] = [] materialized_features.append(output) last_feature_id = feature_id - if len(sketches) != len(materialized_sketches): + if sketch_index != len(sketches): raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch") document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features}) return document, { @@ -475,6 +780,8 @@ def materialize_autonomous_fragment( "source_fragment": deepcopy(fragment), "normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None, "compatibility_fixes": compatibility_fixes, + "compatibility_fix_count": len(compatibility_fixes), + "legacy_input": source != "tool_call" or bool(compatibility_fixes), "assigned_sketch_ids": [item["id"] for item in materialized_sketches], "assigned_feature_ids": [item["id"] for item in materialized_features], "selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])], diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index 5cc11e55..bd88082b 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -148,8 +148,8 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: raise ValueError(f"Training-unsafe CDSL field: {key}") features = cdsl.get("features") sketches = cdsl.get("geometry", {}).get("sketches") - if not isinstance(features, list) or not features or not isinstance(sketches, list) or not sketches: - raise ValueError("CDSL requires features and parameterized 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") sketch_ids = {str(sketch.get("id")) for sketch in sketches} semantic_contract = _engine_schema(engine) atomic_contracts = semantic_contract["feature_atomic_ids"] diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 58a89992..86176810 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import re import secrets +from copy import deepcopy from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -192,6 +193,10 @@ class WorkspaceStore: "lifecycle": "completed", "run_id": "", "requirements_path": "", + "completion_checklist_path": "", + "modeling_plan_path": "", + "modeling_plan_review_path": "", + "modeling_plan_version": 0, "agent_state_path": "", "active_candidate_id": "", "active_branch_id": "main", @@ -222,6 +227,9 @@ class WorkspaceStore: "requirements_path": "", "source_requirements_path": "", "completion_checklist_path": "", + "modeling_plan_path": "", + "modeling_plan_review_path": "", + "modeling_plan_version": 0, "agent_state_path": "", "active_candidate_id": "", "active_branch_id": "main", @@ -271,6 +279,15 @@ class WorkspaceStore: write_json(self.task_path(task_id), task) return task + def update_task_fields(self, task_id: str, fields: dict[str, Any]) -> dict[str, Any]: + """Update task metadata without appending a synthetic revision.""" + task = self.ensure_task(task_id, "") + for key, value in fields.items(): + task[str(key)] = deepcopy(value) + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + def read_task(self, task_id: str) -> dict[str, Any] | None: task = read_json(self.task_path(task_id)) return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None @@ -293,8 +310,8 @@ class WorkspaceStore: return task def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]: - if lifecycle not in {"completed", "failed"}: - raise ValueError("Generation lifecycle must be completed or failed") + if lifecycle not in {"completed", "failed", "cancelled", "waiting_review", "failed_review_service"}: + raise ValueError("Generation lifecycle must be completed, failed, cancelled, waiting_review, or failed_review_service") task = self.ensure_task(task_id, "") failure_path = "" if failure: @@ -421,6 +438,53 @@ class WorkspaceStore: path = self.artifact_path(task_id, relative) return path.read_text(encoding="utf-8") if path.is_file() else "" + def write_modeling_plan(self, task_id: str, markdown: str, *, version: int = 1) -> Path: + """Persist one immutable, versioned modeling-plan document.""" + text = str(markdown or "").strip() + if not text: + raise ValueError("modeling plan must not be empty") + task = self.ensure_task(task_id, "") + version = max(1, int(version)) + relative = Path("plans") / f"modeling-plan-v{version}.md" + path = self.task_dir(task_id) / relative + if path.exists(): + raise ValueError("modeling plan version already exists") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text + "\n", encoding="utf-8") + task["modeling_plan_path"] = relative.as_posix() + task["modeling_plan_version"] = version + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return path + + def read_modeling_plan(self, task_id: str) -> str: + task = self.read_task(task_id) or {} + relative = str(task.get("modeling_plan_path") or "") + if not relative: + return "" + path = self.artifact_path(task_id, relative) + return path.read_text(encoding="utf-8") if path.is_file() else "" + + def write_modeling_plan_review(self, task_id: str, review: dict[str, Any], *, version: int) -> Path: + task = self.ensure_task(task_id, "") + version = max(1, int(version)) + relative = Path("plans") / f"modeling-plan-v{version}.review.json" + path = self.task_dir(task_id) / relative + if path.exists(): + raise ValueError("modeling plan review already exists") + path.parent.mkdir(parents=True, exist_ok=True) + write_json(path, review) + task["modeling_plan_review_path"] = relative.as_posix() + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return path + + def read_modeling_plan_review(self, task_id: str) -> dict[str, Any] | None: + task = self.read_task(task_id) or {} + relative = str(task.get("modeling_plan_review_path") or "") + value = read_json(self.artifact_path(task_id, relative), {}) if relative else None + return value if isinstance(value, dict) else None + def new_candidate(self, task_id: str) -> tuple[str, Path]: task = self.ensure_task(task_id, "") candidate_id = f"candidate_{secrets.token_hex(8)}" diff --git a/backend/app/services/visual_review.py b/backend/app/services/visual_review.py index 5a6ce259..ed55014b 100644 --- a/backend/app/services/visual_review.py +++ b/backend/app/services/visual_review.py @@ -5,6 +5,7 @@ from __future__ import annotations import base64 import copy import json +import re from pathlib import Path from typing import Any @@ -67,6 +68,28 @@ CANDIDATE_REVIEW_TOOL = { } +MODELING_PLAN_REVIEW_TOOL = { + "type": "function", + "function": { + "name": "review_modeling_plan", + "description": "Independently review a CAD modeling plan for requirement coverage, coherent step grouping, dependencies, and observable evidence. Never generate or modify CDSL.", + "parameters": { + "type": "object", + "properties": { + "verdict": {"enum": ["pass", "revise"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "issues": {"type": "array", "items": {"type": "object", "properties": {"type": {"type": "string"}, "step_id": {"type": "string"}, "message": {"type": "string"}}, "required": ["type", "message"], "additionalProperties": False}, "maxItems": 20}, + "coverage": {"type": "array", "items": {"type": "object", "properties": {"requirement": {"type": "string"}, "step_id": {"type": "string"}, "status": {"enum": ["covered", "missing"]}, "evidence": {"type": "string"}}, "required": ["requirement", "step_id", "status", "evidence"], "additionalProperties": False}}, + "step_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}}, "required": ["step_id", "status", "notes"], "additionalProperties": False}}, + "action_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}, "required_action_count": {"type": "integer", "minimum": 1}}, "required": ["step_id", "status", "notes", "required_action_count"], "additionalProperties": False}}, + }, + "required": ["verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"], + "additionalProperties": False, + }, + }, +} + + class VisualReviewError(RuntimeError): pass @@ -75,6 +98,51 @@ class _CandidateReviewFormatError(VisualReviewError): """A locally detected invalid reviewer tool result, eligible for one retry.""" +class _ModelingPlanReviewFormatError(VisualReviewError): + """A locally detected invalid modeling-plan verdict, eligible for one retry.""" + + +def _checklist_key(value: Any) -> str: + return " ".join(str(value or "").strip().split()).casefold() + + +def _looks_like_operation_id(value: str) -> bool: + token = str(value or "").strip().casefold() + if not token or "_" not in token: + return token in {"fillet", "chamfer"} + prefixes = ("extrude_", "revolve_", "hole_", "pattern_", "sphere_", "reference_", "cylinder_", "sweep_") + suffixes = ("_add", "_cut", "_blind", "_wizard", "_linear", "_circular", "_mirror") + return token.startswith(prefixes) or token.endswith(suffixes) + + +def _unsupported_operation_mentions(result: dict[str, Any], runtime_operations: list[dict[str, Any]]) -> list[str]: + """Find capability-shaped names in reviewer prose that Runtime cannot execute.""" + supported = { + str(item.get("atomic_id") or "").strip().casefold() + for item in runtime_operations + if isinstance(item, dict) and str(item.get("atomic_id") or "").strip() + } + mentions: set[str] = set() + for section in (result.get("issues"), result.get("coverage"), result.get("step_checks"), result.get("action_checks")): + if not isinstance(section, list): + continue + for row in section: + if not isinstance(row, dict): + continue + for value in row.values(): + if not isinstance(value, str): + continue + for token in re.findall(r"(? bool: """Recognize the only compatibility error for which retrying is sound. @@ -99,6 +167,10 @@ def candidate_review_tool() -> dict[str, Any]: return json.loads(json.dumps(CANDIDATE_REVIEW_TOOL)) +def modeling_plan_review_tool() -> dict[str, Any]: + return json.loads(json.dumps(MODELING_PLAN_REVIEW_TOOL)) + + def _image_part(path: Path) -> dict[str, Any]: encoded = base64.b64encode(path.read_bytes()).decode("ascii") media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" @@ -222,6 +294,9 @@ async def review_candidate_batch( batch_goal: str, deterministic_report: dict[str, Any], node_id: str, + plan_step: dict[str, Any] | None = None, + plan_feature_ids: list[str] | None = None, + plan_action: dict[str, Any] | None = None, ) -> dict[str, Any]: """Review a staged batch against its local goal and the frozen checklist.""" provider, model = settings.resolve_review_model() @@ -237,6 +312,9 @@ async def review_candidate_batch( "frozen_requirements": requirements, "completion_checklist": checklist, "batch_goal": batch_goal, + "plan_step": plan_step or None, + "plan_feature_ids": plan_feature_ids or [], + "plan_action": plan_action or None, "deterministic_report": deterministic_report, "instruction": ( "Assess this staged batch, not overall task completion. Accept only if the batch goal is achieved " @@ -247,7 +325,9 @@ async def review_candidate_batch( "intermediate model may be accepted when its batch goal is achieved. Return one coverage row for every " "completion checklist item, preserving exact item text. Use pending for future work and regressed when " "this batch breaks previously achieved work. Reject on disconnected geometry when the requirements call " - "for one body, wrong orientation, visibly wrong geometry, or a failed batch goal." + "for one body, wrong orientation, visibly wrong geometry, or a failed batch goal. " + "When plan_step is supplied, reject operations outside that step and confirm its planned feature goal. " + "A step may contain multiple related actions; when plan_action is supplied, assess only that action, confirm the candidate changes its target, and reject unrelated or multiple independent profiles." ), "render_manifest": { "renderer": manifest.get("renderer"), @@ -341,3 +421,171 @@ async def review_candidate_batch( "batch_goal": batch_goal, **result, } + + +async def review_modeling_plan( + settings: Settings, + *, + source_requirements: str, + requirements: str, + checklist: list[str], + plan: dict[str, Any], + runtime_operations: list[dict[str, Any]], + node_id: str = "modeling-plan", +) -> dict[str, Any]: + """Ask an independent model to simulate and review a modeling plan.""" + provider, model = settings.resolve_review_model() + payload_context = { + "node_id": node_id, + "source_requirements": source_requirements, + "frozen_requirements": requirements, + "completion_checklist": checklist, + "modeling_plan": plan, + "runtime_operations": runtime_operations, + "instruction": ( + "Review the plan only; do not generate CDSL. Treat modeling_plan.plan_text as semantic " + "guidance, not a schema: missing structures/features arrays or machine IDs are not by " + "themselves failures. Infer intended structures, ordering, relationships, and evidence " + "from the prose. Check every checklist item, dependency order, step cohesion, topology " + "preconditions, Runtime feasibility, action granularity, and observable evidence. A semantic " + "step may contain related or repeated targets, but each independently located profile " + "or Runtime feature must be enumerated as a separate action within that same step. Each action " + "must be executable as one Runtime feature. A multi-position hole or supported pattern may remain " + "one action when all positions share the same host and operation contract. Treat any supplied " + "action records as execution units, and set required_action_count to the minimum number the step's " + "semantics require. Return fail when the plan contains fewer actions than that number. Return pass when the plan " + "is actionable enough for an LLM to generate CDSL in coherent batches. Do not require " + "colors or materials when runtime_operations does not provide them; accept a geometrically " + "equivalent ring, groove, or separated feature and mention the limitation only as guidance. " + "Only return revise for a real requirement omission, contradictory geometry, unsafe ordering, " + "an operation name that is not present in runtime_operations, or an operation that cannot plausibly be implemented. " + "Do not downgrade an unsupported operation to vague guidance. If a machine operation is mentioned, it must be an exact ID from runtime_operations; otherwise return revise and describe the semantic intent without inventing an operation name. " + "Return exactly one coverage row per checklist item." + ), + } + payload = { + "model": model.id, + "messages": [ + {"role": "system", "content": "You are an independent CAD modeling-plan reviewer. You may only call review_modeling_plan."}, + {"role": "user", "content": json.dumps(payload_context, ensure_ascii=False)}, + ], + "tools": [modeling_plan_review_tool()], + "tool_choice": {"type": "function", "function": {"name": "review_modeling_plan"}}, + "temperature": 0, + } + payload.update(provider.chat_completion_options) + headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} + + async def request_review(client: httpx.AsyncClient, request_payload: dict[str, Any]) -> httpx.Response: + response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload) + if _thinking_tool_choice_rejected(response): + request_payload.pop("tool_choice", None) + response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload) + if response.status_code >= 400: + raise VisualReviewError(f"Modeling plan review request failed ({response.status_code}): {response.text[:500]}") + return response + + def validate_response(response: httpx.Response) -> dict[str, Any]: + try: + call = response.json()["choices"][0]["message"]["tool_calls"][0] + if call["function"]["name"] != "review_modeling_plan": + raise KeyError("wrong tool") + result = json.loads(call["function"]["arguments"]) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error: + raise _ModelingPlanReviewFormatError("Modeling plan reviewer did not return a valid review tool call") from error + allowed = {"verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"} + if not isinstance(result, dict) or set(result) != allowed or result.get("verdict") not in {"pass", "revise"}: + raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid verdict") + try: + confidence = float(result.get("confidence")) + except (TypeError, ValueError) as error: + raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid confidence") from error + if not 0 <= confidence <= 1: + raise _ModelingPlanReviewFormatError("Modeling plan reviewer confidence is outside [0, 1]") + if not isinstance(result.get("issues"), list) or not all(isinstance(item, dict) for item in result["issues"]): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned invalid issues") + expected = {_checklist_key(item): item for item in checklist} + coverage = result.get("coverage") + if not isinstance(coverage, list) or len(coverage) != len(checklist): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return coverage for every checklist item") + seen: set[str] = set() + for item in coverage: + if not isinstance(item, dict) or set(item) != {"requirement", "step_id", "status", "evidence"}: + raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid coverage row") + key = _checklist_key(item.get("requirement")) + if key not in expected or key in seen or item.get("status") not in {"covered", "missing"} or not str(item.get("evidence") or "").strip(): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer coverage does not match the frozen checklist") + item["requirement"] = expected[key] + seen.add(key) + step_records = { + str(item.get("step_id") or ""): item + for item in plan.get("steps") or () if isinstance(item, dict) + } + steps = set(step_records) + checks = result.get("step_checks") + if not isinstance(checks, list) or len(checks) != len(steps): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one step check per plan step") + checked: set[str] = set() + for item in checks: + if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes"}: + raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid step check") + step_id = str(item.get("step_id") or "") + if step_id not in steps or step_id in checked or item.get("status") not in {"pass", "fail"}: + raise _ModelingPlanReviewFormatError("Modeling plan reviewer step checks do not match the plan") + checked.add(step_id) + action_checks = result.get("action_checks") + if not isinstance(action_checks, list) or len(action_checks) != len(steps): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one action check per plan step") + action_checked: set[str] = set() + for item in action_checks: + if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes", "required_action_count"}: + raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid action check") + step_id = str(item.get("step_id") or "") + required_count = item.get("required_action_count") + if ( + step_id not in steps + or step_id in action_checked + or item.get("status") not in {"pass", "fail"} + or not isinstance(required_count, int) + or isinstance(required_count, bool) + or required_count < 1 + ): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer action checks do not match the plan") + planned_actions = [ + action for action in (step_records[step_id].get("actions") or ()) + if isinstance(action, dict) + ] + if required_count > len(planned_actions) and item.get("status") != "fail": + raise _ModelingPlanReviewFormatError( + "Modeling plan reviewer must fail an action check when required_action_count exceeds the plan's action count" + ) + action_checked.add(step_id) + if result["verdict"] == "pass" and any(item.get("status") != "covered" for item in coverage): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every checklist item is covered") + if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in checks): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every step check passes") + if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in action_checks): + raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every action check passes") + unsupported = _unsupported_operation_mentions(result, runtime_operations) + if unsupported: + # These strings came from the reviewer's explanatory prose, not + # from the submitted plan. The plan parser enforces exact IDs for + # explicit operation declarations; do not reject an otherwise + # valid semantic plan because the reviewer hallucinated a + # shorthand while describing an alternative. Keep this warning in + # the raw audit record and scrub it before author exposure. + result["unsupported_operations"] = unsupported + result["review_warnings"] = [ + "Reviewer mentioned operation name(s) absent from runtime_operations; those names were ignored as guidance." + ] + return result + + async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client: + response = await request_review(client, payload) + try: + result = validate_response(response) + except _ModelingPlanReviewFormatError as error: + retry_payload = copy.deepcopy(payload) + retry_payload["messages"].append({"role": "user", "content": f"Your previous review was rejected locally: {error}. Return only a complete review_modeling_plan tool call with one coverage row per checklist item and one step_checks and action_checks row per plan step."}) + result = validate_response(await request_review(client, retry_payload)) + return {"schema_version": "cad.modeling-plan-review.v1", "node_id": node_id, "model": model.id, **result} diff --git a/backend/app/settings.py b/backend/app/settings.py index 03ed21ea..2ddd9d74 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -65,6 +65,8 @@ class Settings: agent_max_features_per_fragment: int = 6 agent_context_char_limit: int = 14000 agent_render_cache: bool = True + modeling_plan_enabled: bool = True + modeling_plan_max_revisions: int = 2 autonomous_generation: bool = True resume_running_tasks_on_startup: bool = True @@ -199,6 +201,8 @@ def get_settings() -> Settings: agent_max_features_per_fragment=max(1, min(6, int(os.getenv("CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT", "6")))), agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))), agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True), + modeling_plan_enabled=_env_flag("CDSL_MODELING_PLAN_ENABLED", True), + modeling_plan_max_revisions=max(1, int(os.getenv("CDSL_MODELING_PLAN_MAX_REVISIONS", "2"))), autonomous_generation=True, # Production instances recover durable runs by default. Test workers # can disable this before startup to guarantee they touch only tasks diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 10dd6fa4..51d8cce4 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -13,7 +13,7 @@ "geometry": { "type": "object", "properties": { - "sketches": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/sketch"}} + "sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}} }, "required": ["sketches"], "additionalProperties": false diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index cb49168e..0d9830b1 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -15,7 +15,7 @@ "hole_blind": {"summary": "Cut one or more blind cylindrical holes in the current body.", "required_params": ["diameter_mm", "depth_mm", "positions", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false}, "hole_countersink": {"summary": "Cut one or more blind holes with countersink dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "countersink_diameter_mm", "countersink_angle_rad", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false}, "hole_counterbore": {"summary": "Cut one or more blind holes with counterbore dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "counterbore_diameter_mm", "counterbore_depth_mm", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false}, - "sphere_add": {"summary": "Add one spherical solid at an explicit model-space center.", "required_params": ["radius_mm", "center_mm"], "optional_params": [], "requires_sketch": true, "produces_body": true}, + "sphere_add": {"summary": "Add one spherical solid at an explicit model-space center.", "required_params": ["radius_mm", "center_mm"], "optional_params": [], "requires_sketch": false, "produces_body": true}, "fillet": {"summary": "Apply a radius to selected edges or faces.", "required_params": ["radius_mm"], "optional_params": ["tangent_propagation"], "requires_sketch": false, "selector_slot": {"path": "feature.selectors", "min_items": 1, "max_items": 64}}, "chamfer": {"summary": "Apply an equal-distance or angle-distance chamfer to selected edges or faces.", "required_params": ["distance_mm"], "optional_params": ["distance_2_mm", "angle_rad"], "requires_sketch": false, "selector_slot": {"path": "feature.selectors", "min_items": 1, "max_items": 64}}, "pattern_linear": {"summary": "Repeat source features along one or two directions.", "required_params": ["source_feature_ids", "direction_1", "spacing_1_mm", "pattern_count_1"], "optional_params": ["direction_2", "spacing_2_mm", "pattern_count_2"], "requires_sketch": false}, diff --git a/backend/engine/cdsl_engine/rebuild.py b/backend/engine/cdsl_engine/rebuild.py index 7f965e73..5d3815a1 100644 --- a/backend/engine/cdsl_engine/rebuild.py +++ b/backend/engine/cdsl_engine/rebuild.py @@ -39,9 +39,10 @@ def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = No # macro profiles and therefore never invoke this adapter. cdsl = lower_legacy_profiles(cdsl) sketches = cdsl.get("geometry", {}).get("sketches", []) - all_drawable = bool(sketches) and all( - _sketch_is_cdsl_drawable(s) for s in sketches - ) + # Sketchless parameterized features (for example sphere_add) are fully + # executable by the CDSL-only runtime. ``all([])`` deliberately keeps + # that path available rather than forcing an unavailable legacy fallback. + all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches) cdsl_only_error: Exception | None = None if all_drawable and not force_exact: diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 5a4ab785..6b93ca3f 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -373,6 +373,23 @@ def _revolve_axis(node: FeaturePlanNode, session: ExecutionSession) -> AxisSpec: return resolution.record.value +def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None: + """Defend direct CDSL execution from an out-of-plane revolve axis.""" + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + direction_normal_dot = abs(vector_dot(axis.direction, plane.normal)) + if direction_normal_dot > 1e-7: + raise ValueError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to " + f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" + ) + origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal)) + if origin_plane_offset > 1e-6: + raise ValueError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in " + f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}" + ) + + def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, sketch: dict[str, Any] | None = None) -> FeatureResult: # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 @@ -402,6 +419,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske else: # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 axis = _revolve_axis(node, session) + _validate_revolve_axis_in_sketch_plane(axis, selected_sketch) angle = float(node.params.get("angle_deg") or 0.0) if angle <= 0: raise ValueError("revolve requires angle_deg > 0") diff --git a/backend/tests/test_autonomous_cdsl_generation.py b/backend/tests/test_autonomous_cdsl_generation.py index 676f4705..42dce733 100644 --- a/backend/tests/test_autonomous_cdsl_generation.py +++ b/backend/tests/test_autonomous_cdsl_generation.py @@ -17,16 +17,22 @@ from app.services.autonomous_cdsl_generation import ( # noqa: E402 AutonomousCdslGenerationRunner, AutonomousGenerationError, _format_correction_card, + _fragment_atomic_ids, + _fragment_feature_count, _fragment_selector_tokens, _requires_edge_selector_recovery, _final_repair_card, _checkpoint_token, _geometry_fingerprint, _has_material_volume_change, + _global_geometry_violations, + _is_engine_geometry_failure, _material_volume_tolerance, _operation_contract_payload, + _public_modeling_plan_review, parse_completion_audit, parse_completion_checklist, + parse_modeling_plan, _preferred_tool_call, _is_author_quota_error, _is_author_transport_error, @@ -103,6 +109,75 @@ def accepted_plate_batch_review() -> dict[str, object]: class AutonomousStorageTests(unittest.TestCase): + def test_modeling_plan_template_is_validated_without_lowering_to_cdsl(self) -> None: + plan_text = "\n".join([ + "# Structures", "[STRUCTURE base]", "role: body", "requirements:", "- rectangular plate", "depends_on: none", + "# Features", "[FEATURE profile]", "structure: base", "operation: sketch_profile", "purpose: define outline", "depends_on: none", "topology_sensitive: no", "evidence: measure_model", + "[FEATURE solid]", "structure: base", "operation: extrude_add_blind", "purpose: create solid", "depends_on: profile", "topology_sensitive: no", "evidence: measure_model", + "# Steps", "[STEP step_1]", "goal: create base", "features: profile, solid", "covers:", "- rectangular plate", "relationship: same sketch and extrusion target", "prerequisites: none", "expected_state_change: one solid", "evidence: measure_model", + ]) + plan = parse_modeling_plan(plan_text, checklist=["rectangular plate"], runtime_atomic_ids={"extrude_add_blind"}) + self.assertEqual(plan["steps"][0]["feature_ids"], ["profile", "solid"]) + self.assertEqual(plan["features"][1]["depends_on"], ["profile"]) + + def test_modeling_plan_keeps_semantic_coverage_for_independent_reviewer(self) -> None: + plan = parse_modeling_plan( + "[STRUCTURE base]\nname: body\n[FEATURE solid]\nstructure: base\noperation: extrude_add_blind with a circular sketch\npurpose: body\n[STEP step_1]\ngoal: body\nfeatures: solid\ncovers: required hole\nrelationship: same body\n", + checklist=["required hole"], runtime_atomic_ids={"extrude_add_blind"}, + ) + self.assertEqual(plan["steps"][0]["step_id"], "step_1") + self.assertIn("extrude_add_blind with a circular sketch", plan["features"][0]["operation"]) + + def test_modeling_plan_rejects_explicit_unsupported_runtime_operation(self) -> None: + with self.assertRaisesRegex(AutonomousGenerationError, "PLAN_OPERATION_UNSUPPORTED:.*extrude_add"): + parse_modeling_plan( + "[FEATURE bolt]\noperation: extrude_add\npurpose: bolt head\n", + checklist=["bolt head"], + runtime_atomic_ids={"extrude_add_blind"}, + ) + + def test_public_plan_review_scrubs_unsupported_operation_names(self) -> None: + public = _public_modeling_plan_review({ + "verdict": "revise", + "issues": [{"message": "Prefer extrude_add for the boss."}], + "unsupported_operations": ["extrude_add"], + }) + self.assertNotIn("extrude_add", json.dumps(public)) + self.assertIn("unsupported_runtime_operation", json.dumps(public)) + + def test_modeling_plan_accepts_free_text_and_optional_step_headings(self) -> None: + plan = parse_modeling_plan( + "先建立沿 X 轴的长轴,再添加法兰和套筒。\n\n## STEP 2 前端细节\n增加环槽和保持器。", + checklist=["长轴", "环槽"], + ) + self.assertEqual([item["step_id"] for item in plan["steps"]], ["step_1", "step_2"]) + self.assertEqual(plan["steps"][1]["title"], "前端细节") + + def test_plain_prose_plan_is_semantic_only(self) -> None: + plan = parse_modeling_plan("先创建底座,再添加支撑和孔。", checklist=["底座"]) + self.assertEqual(plan["mode"], "semantic_only") + self.assertEqual(plan["steps"][0]["step_id"], "semantic_plan") + + def test_global_geometry_blocks_multiple_solids_and_height_drift(self) -> None: + violations = _global_geometry_violations( + { + "solid_count": 2, + "bbox_mm": {"dimensions": [100.0, 32.0, 99.5]}, + }, + requirements="整体为单个机械零件;支撑座总高度约 82 mm。", + checklist=["所有主要实体相互连接"], + ) + self.assertEqual({item["code"] for item in violations}, {"SOLID_COUNT_MISMATCH", "DIMENSION_OUT_OF_TOLERANCE"}) + + def test_global_geometry_can_defer_final_height_for_initial_step(self) -> None: + violations = _global_geometry_violations( + {"solid_count": 1, "bbox_mm": {"dimensions": [120.0, 38.0, 20.0]}}, + requirements="支撑座总高度约 95 mm。", + checklist=["存在水平长方体底座"], + check_dimensions=False, + ) + self.assertEqual(violations, []) + def test_completion_checklist_is_free_markdown_but_has_unique_pending_items(self) -> None: items = parse_completion_checklist( "# Completion\n- [ ] one coherent solid\n- [ ] six holes in each flange\n" @@ -205,6 +280,295 @@ class AutonomousStorageTests(unittest.TestCase): self.assertEqual(card["valid_feature_shape"]["params"], {"distance_mm": "positive number"}) self.assertEqual([tool["function"]["name"] for tool in tools], ["submit_cdsl_fragment"]) + def test_shared_revolve_axis_missing_angle_gets_a_specific_correction(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "sketch": base_fragment()["sketch"], + "feature": {"atomic_id": "revolve_add", "params": {}}, + } + card = _format_correction_card( + engine, + fragment_json=json.dumps(fragment), + error_message=( + "revolve_add requires params.angle_deg; revolve_axis (including shared_revolve_axis) " + "supplies only params.axis. Declare an explicit angle in degrees." + ), + head="main:root", + shared_revolve_axis={"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + ) + + self.assertEqual(card["missing_author_params"], ["angle_deg"]) + self.assertIn("shared_revolve_axis", card["shared_revolve_axis_note"]) + self.assertIn("partial revolutions are valid", card["instruction"]) + self.assertEqual(card["valid_feature_shape"]["params"]["angle_deg"], "positive number up to 360") + + def test_hole_correction_reports_every_author_error_in_one_card(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "feature": { + "atomic_id": "hole_blind", + "params": {"positions_mm": [[0, 0, 0]], "direction": [0, 0, -1], "host_face": "top"}, + }, + } + card = _format_correction_card( + engine, + fragment_json=json.dumps(fragment), + error_message=( + "HOLE_FRAGMENT_INVALID: params.host_face is server-owned; use selector_tokens; " + "missing params: diameter_mm, depth_mm, positions; unsupported params: direction, positions_mm; " + "hole_blind requires exactly one face selector token for its host face" + ), + head="main:rev_002", + ) + + self.assertEqual(card["server_owned_attempted_params"], ["host_face"]) + self.assertEqual(card["unsupported_attempted_params"], ["direction", "host_face", "positions_mm"]) + self.assertIn("missing params: diameter_mm, depth_mm, positions", card["issues"]) + self.assertIn("unsupported params: direction, positions_mm", card["issues"]) + self.assertEqual(card["valid_feature_shape"]["selector_tokens"], ["exactly one current face token"]) + self.assertEqual(card["valid_feature_shape"]["params"]["positions"], [{"mm": "[x_mm, y_mm, z_mm]"}]) + + def test_analytic_contour_correction_exposes_the_actual_contour_shape(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "sketch": { + "workplane": base_fragment()["sketch"]["workplane"], + "profile": { + "type": "analytic_contours", + "contours": [ + {"type": "circle", "center": [-45, 0], "radius_mm": 5}, + {"type": "circle", "center": [45, 0], "radius_mm": 5}, + ], + }, + }, + "feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 4}}, + } + card = _format_correction_card( + engine, + fragment_json=json.dumps(fragment), + error_message="CDSL schema violation at $.geometry.sketches[3].profile: analytic_contours is not valid under any of the given schemas", + head="main:rev_002", + ) + + profile = card["profile_correction"] + self.assertEqual(profile["valid_profile_shape"]["contours"][0]["segments"][0]["type"], "circle") + self.assertIn("one outer contour per separate cut island", profile["rules"][1]) + + def test_multi_feature_fragment_requires_an_explicit_relationship(self) -> None: + async def exercise() -> tuple[list[tuple[str, dict]], dict]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "batch relationship") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen\nBuild a bracket.") + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + state = {"candidate_attempts_by_head": {}, "read_operation_contract_ids": []} + events, _ = await runner._execute_tool( + task_id, + "batch relationship", + state, + load_engine(current_settings), + "submit_cdsl_fragment", + { + "batch_goal": "Create two related bracket cuts.", + "fragment_json": json.dumps({"features": [{"atomic_id": "extrude_cut_blind"}, {"atomic_id": "extrude_cut_blind"}]}), + }, + ) + return events, state + + events, state = asyncio.run(exercise()) + self.assertIn("BATCH_RELATIONSHIP_REQUIRED", events[0][1]["message"]) + self.assertEqual(_fragment_feature_count({"features": [{"atomic_id": "a"}, {"atomic_id": "a"}]}), 2) + self.assertEqual(state["format_correction"]["attempted_feature_count"], 2) + + def test_first_use_of_an_operation_requires_its_contract(self) -> None: + async def exercise() -> tuple[list[tuple[str, dict]], dict, list[str]]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "contract gate") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen\nBuild a plate.") + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + state: dict = {"candidate_attempts_by_head": {}, "read_operation_contract_ids": []} + engine = load_engine(current_settings) + + events, progressed = await runner._execute_tool( + task_id, + "contract gate", + state, + engine, + "submit_cdsl_fragment", + {"batch_goal": "Create the initial rectangular plate.", "fragment_json": json.dumps(base_fragment())}, + ) + self.assertFalse(progressed) + self.assertEqual(state["candidate_action_required"]["reason"], "operation_contract_required") + self.assertEqual(state["candidate_action_required"]["atomic_ids"], ["extrude_add_blind"]) + allowed = [ + tool["function"]["name"] + for tool in runner._author_tools(store.read_task(task_id) or {}, requirements_frozen=True, state=state) + ] + self.assertEqual(allowed, ["get_cdsl_operation_contract"]) + + _, progressed = await runner._execute_tool( + task_id, + "contract gate", + state, + engine, + "get_cdsl_operation_contract", + {"atomic_id": "extrude_add_blind"}, + ) + self.assertTrue(progressed) + return events, state, [ + tool["function"]["name"] + for tool in runner._author_tools(store.read_task(task_id) or {}, requirements_frozen=True, state=state) + ] + + events, state, tools = asyncio.run(exercise()) + + self.assertIn("OPERATION_CONTRACT_REQUIRED", events[0][1]["message"]) + self.assertEqual(state["read_operation_contract_ids"], ["extrude_add_blind"]) + self.assertEqual(state["candidate_action_required"], {}) + self.assertEqual(tools, ["submit_cdsl_fragment"]) + self.assertEqual(_fragment_atomic_ids({"features": [{"atomic_id": "extrude_add_blind"}, {"feature": {"atomic_id": "revolve_add"}}]}), ["extrude_add_blind", "revolve_add"]) + + def test_selector_fragment_requires_a_current_topology_observation(self) -> None: + async def exercise() -> tuple[list[tuple[str, dict]], dict, list[str]]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "selector observation gate") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen\nBuild a plate.") + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + state: dict = {"candidate_attempts_by_head": {}, "read_operation_contract_ids": ["hole_blind"]} + fragment = { + "feature": { + "atomic_id": "hole_blind", + "params": {"diameter_mm": 4, "depth_mm": 6, "positions": [{"mm": [0, 0, 0]}]}, + "selector_tokens": ["sel_invented"], + }, + } + events, progressed = await runner._execute_tool( + task_id, + "selector observation gate", + state, + load_engine(current_settings), + "submit_cdsl_fragment", + {"batch_goal": "Create one mounting hole on the observed face.", "fragment_json": json.dumps(fragment)}, + ) + tools = [ + item["function"]["name"] + for item in runner._author_tools(store.read_task(task_id) or {}, requirements_frozen=True, state=state) + ] + self.assertFalse(progressed) + return events, state, tools + + events, state, tools = asyncio.run(exercise()) + + self.assertIn("TOPOLOGY_SNAPSHOT_OBSERVATION_REQUIRED", events[0][1]["message"]) + self.assertEqual(state["format_correction"], {}) + self.assertEqual(state["candidate_action_required"]["reason"], "topology_selector_recovery") + self.assertEqual(tools, ["inspect_topology", "rollback_checkpoint"]) + + def test_hole_failure_keeps_all_corrections_while_requiring_a_fresh_face_token(self) -> None: + async def exercise() -> tuple[list[tuple[str, dict]], dict, list[str]]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "aggregate hole recovery") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen\nBuild a plate.") + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + state = {"candidate_attempts_by_head": {}, "read_operation_contract_ids": ["hole_blind"]} + fragment = { + "feature": { + "atomic_id": "hole_blind", + "params": {"positions_mm": [[0, 0, 0]], "direction": [0, 0, -1]}, + }, + } + events, _ = await runner._execute_tool( + task_id, "aggregate hole recovery", state, load_engine(current_settings), "submit_cdsl_fragment", + {"batch_goal": "Create one mounting hole on the observed face.", "fragment": fragment}, + ) + tools = [ + item["function"]["name"] + for item in runner._author_tools(store.read_task(task_id) or {}, requirements_frozen=True, state=state) + ] + return events, state, tools + + events, state, tools = asyncio.run(exercise()) + self.assertIn("HOLE_FRAGMENT_INVALID", events[0][1]["message"]) + self.assertIn("missing params: diameter_mm, depth_mm, positions", events[0][1]["message"]) + self.assertIn("unsupported params: direction, positions_mm", events[0][1]["message"]) + self.assertEqual(state["candidate_action_required"]["reason"], "topology_selector_recovery") + self.assertIn("missing params: diameter_mm, depth_mm, positions", state["format_correction"]["issues"]) + self.assertEqual(tools, ["inspect_topology", "rollback_checkpoint"]) + + def test_brep_and_step_failures_do_not_enter_format_correction(self) -> None: + self.assertTrue(_is_engine_geometry_failure("STEP tessellation produced no renderable triangles")) + self.assertTrue(_is_engine_geometry_failure("'NoneType' object has no attribute 'NbNodes'")) + self.assertFalse(_is_engine_geometry_failure("CDSL schema violation at $.features[0].params")) + + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + runner = AutonomousCdslGenerationRunner(current_settings, WorkspaceStore(current_settings), AsyncMock()) + task = {"active_revision": "rev_001", "active_branch_id": "main"} + state = { + "candidate_action_required": { + "working_head": "main:rev_001", + "reason": "engine_geometry_invalid", + }, + } + + names = { + item["function"]["name"] + for item in runner._author_tools(task, requirements_frozen=True, state=state) + } + + self.assertIn("render_views", names) + self.assertIn("submit_cdsl_fragment", names) + self.assertIn("rollback_checkpoint", names) + + def test_step_tessellation_failure_enters_geometry_recovery_not_format_correction(self) -> None: + async def exercise() -> tuple[list[tuple[str, dict]], dict]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "tessellation recovery") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen\nBuild a plate.") + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + state: dict = { + "candidate_attempts_by_head": {}, + "read_operation_contract_ids": ["extrude_add_blind"], + } + with patch( + "app.services.autonomous_cdsl_generation.build_candidate", + side_effect=AutonomousGenerationError("STEP tessellation produced no renderable triangles"), + ): + events, progressed = await runner._execute_tool( + task_id, + "tessellation recovery", + state, + load_engine(current_settings), + "submit_cdsl_fragment", + {"batch_goal": "Create the initial rectangular plate.", "fragment_json": json.dumps(base_fragment())}, + ) + self.assertFalse(progressed) + return events, state + + events, state = asyncio.run(exercise()) + + self.assertEqual(events[0][0], "candidate_result") + self.assertEqual(state["format_correction"], {}) + self.assertEqual(state["candidate_action_required"]["reason"], "engine_geometry_invalid") + self.assertIn("tessellation", state["engine_geometry_failure"]["error"]) + def test_identical_format_error_increments_only_its_own_signature(self) -> None: with tempfile.TemporaryDirectory() as temporary: engine = load_engine(settings(Path(temporary))) @@ -684,6 +1048,28 @@ class AutonomousFragmentTests(unittest.TestCase): self.assertEqual(contract["server_injected_params"], []) self.assertIsNone(contract["selector_rule"]) self.assertEqual(cdsl["features"][-1]["params"]["axis"]["direction"], [1, 0, 0]) + self.assertEqual(contract["fragment_template"]["feature"]["params"]["angle_deg"], 360) + self.assertEqual(contract["fragment_template"]["feature"]["params"]["axis"]["direction"], [0, 0, 1]) + self.assertEqual(contract["shared_revolve_axis"]["fills"], "feature.params.axis only") + self.assertEqual( + contract["shared_revolve_axis"]["fragment_template"]["feature"]["params"], + {"angle_deg": 360}, + ) + + def test_revolve_missing_angle_is_rejected_before_json_schema_validation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "revolve_axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + "sketch": base_fragment()["sketch"], + "feature": {"atomic_id": "revolve_add", "params": {}}, + } + + with self.assertRaisesRegex( + AutonomousFragmentError, + r"revolve_add requires params\.angle_deg; revolve_axis \(including shared_revolve_axis\) supplies only params\.axis", + ): + materialize_autonomous_fragment(None, fragment, engine=engine, selector_tokens={}, max_features=6) def test_revolve_without_explicit_axis_is_rejected_before_schema_retry_loop(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -696,6 +1082,48 @@ class AutonomousFragmentTests(unittest.TestCase): with self.assertRaisesRegex(AutonomousFragmentError, "requires params.axis"): materialize_autonomous_fragment(None, fragment, engine=engine, selector_tokens={}, max_features=6) + def test_revolve_axis_must_lie_in_its_sketch_plane_before_candidate_staging(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "sketch": base_fragment()["sketch"], + "feature": { + "atomic_id": "revolve_add", + "params": {"angle_deg": 180, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}, + }, + } + + with self.assertRaisesRegex(AutonomousFragmentError, "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE"): + materialize_autonomous_fragment(None, fragment, engine=engine, selector_tokens={}, max_features=1) + + def test_cdsl_only_runtime_rejects_out_of_plane_revolve_axis(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + engine = load_engine(settings(root)) + cdsl = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "runtime-axis-check", + "geometry": {"sketches": [{ + "id": "sketch_001", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "polygon", "vertices": [[-2, -1], [2, -1], [2, 1], [-2, 1]]}, + }]}, + "features": [{ + "id": "feature_001", + "atomic_id": "revolve_add", + "depends_on": [], + "sketch_id": "sketch_001", + "selectors": [], + "params": {"angle_deg": 180, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}, + }], + } + + validate_cdsl(cdsl, engine) + with self.assertRaisesRegex(Exception, "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE"): + engine.run_cdsl_only(cdsl, root / "invalid.step") + def test_revolve_common_axis_aliases_are_normalized_losslessly(self) -> None: with tempfile.TemporaryDirectory() as temporary: current_settings = settings(Path(temporary)) @@ -758,6 +1186,164 @@ class AutonomousFragmentTests(unittest.TestCase): self.assertTrue(any(item["action"] == "copied_explicit_batch_axis" for item in audit["compatibility_fixes"])) self.assertTrue(any(item["from"] == "points" and item["to"] == "vertices" for item in audit["compatibility_fixes"])) + def test_mixed_batch_wrapper_pairs_only_sketch_features(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + engine = load_engine(current_settings) + base_cdsl, _ = materialize_autonomous_fragment( + None, base_fragment(), engine=engine, selector_tokens={}, max_features=6 + ) + fragment = { + "features": [ + { + "feature": { + "atomic_id": "sphere_add", + "params": {"radius_mm": 3, "center_mm": [0, 0, 0]}, + }, + }, + { + "sketch": { + "workplane": {"origin_mm": [0, 0, 4], "x_dir": [1, 0, 0], "normal": [0, 0, -1]}, + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}, + }, + "feature": { + "atomic_id": "extrude_cut_blind", + "params": {"depth_mm": 2, "direction": [0, 0, -1]}, + }, + }, + ], + } + + cdsl, audit = materialize_autonomous_fragment( + base_cdsl, fragment, engine=engine, selector_tokens={}, max_features=6 + ) + cdsl["part_id"] = "mixed-wrapper-batch" + validate_cdsl(cdsl, engine) + + sphere, cut = cdsl["features"][-2:] + self.assertNotIn("sketch_id", sphere) + self.assertIn("sketch_id", cut) + self.assertEqual(cut["params"], {"distance_mm": 2}) + self.assertTrue(any(item["action"] == "unwrapped_equivalent" for item in audit["compatibility_fixes"])) + + def test_param_embedded_sketch_and_named_direction_are_normalized(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "features": [{ + "atomic_id": "extrude_add_blind", + "params": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 5}, + "depth_mm": 8, + "direction": "negative", + }, + }], + } + + cdsl, audit = materialize_autonomous_fragment( + None, fragment, engine=engine, selector_tokens={}, max_features=6 + ) + cdsl["part_id"] = "embedded-param-sketch" + validate_cdsl(cdsl, engine) + + self.assertEqual(cdsl["features"][0]["params"], {"distance_mm": 8, "reverse": True}) + self.assertEqual(cdsl["geometry"]["sketches"][0]["profile"]["type"], "circle") + self.assertTrue(any(item["from"] == "workplane/profile" for item in audit["compatibility_fixes"])) + + def test_revolve_angle_radians_and_wrapper_shape_are_normalized(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "revolve_axis": {"origin_mm": [0, 0, 0], "direction": [1, 0, 0]}, + "features": [{ + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]}, + "profile": {"type": "polygon", "vertices": [[-2, 1], [2, 1], [2, 3], [-2, 3]]}, + }, + "feature": {"atomic_id": "revolve_add", "params": {"angle_rad": 6.283185307179586}}, + }], + } + + cdsl, audit = materialize_autonomous_fragment( + None, fragment, engine=engine, selector_tokens={}, max_features=6 + ) + cdsl["part_id"] = "radian-revolve" + validate_cdsl(cdsl, engine) + + self.assertAlmostEqual(cdsl["features"][0]["params"]["angle_deg"], 360) + self.assertEqual(cdsl["features"][0]["params"]["axis"]["direction"], [1, 0, 0]) + self.assertTrue(any(item["action"] == "converted_unit" for item in audit["compatibility_fixes"])) + + def test_concentric_circle_shorthand_is_expanded_to_analytic_contours(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": { + "type": "analytic_contours", + "contours": [ + {"type": "circle", "center": [0, 0], "radius_mm": 12}, + {"type": "circle", "center": [0, 0], "radius_mm": 10.8}, + ], + }, + }, + "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 1.2}}, + } + + cdsl, audit = materialize_autonomous_fragment( + None, fragment, engine=engine, selector_tokens={}, max_features=6 + ) + cdsl["part_id"] = "concentric-circle-ring" + validate_cdsl(cdsl, engine) + + contours = cdsl["geometry"]["sketches"][0]["profile"]["contours"] + self.assertEqual([contour["role"] for contour in contours], ["outer", "inner"]) + self.assertTrue(any(item["action"] == "expanded_equivalent" for item in audit["compatibility_fixes"])) + + def test_sphere_add_materializes_without_a_placeholder_sketch(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "feature": { + "atomic_id": "sphere_add", + "params": {"radius_mm": 2.5, "center_mm": [3, -4, 5]}, + }, + } + + cdsl, _ = materialize_autonomous_fragment( + None, fragment, engine=engine, selector_tokens={}, max_features=6 + ) + cdsl["part_id"] = "sphere-without-sketch" + validate_cdsl(cdsl, engine) + + self.assertEqual(cdsl["geometry"]["sketches"], []) + self.assertNotIn("sketch_id", cdsl["features"][0]) + + def test_legacy_sphere_locator_sketch_is_dropped_without_changing_geometry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + fragment = { + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "radius_mm": 1}, + }, + "feature": { + "atomic_id": "sphere_add", + "params": {"radius_mm": 2.5, "center_mm": [3, -4, 5]}, + }, + } + + cdsl, audit = materialize_autonomous_fragment( + None, fragment, engine=engine, selector_tokens={}, max_features=6 + ) + cdsl["part_id"] = "legacy-sphere-locator" + validate_cdsl(cdsl, engine) + + self.assertEqual(cdsl["geometry"]["sketches"], []) + self.assertTrue(any(item["action"] == "dropped_unused_legacy_locator" for item in audit["compatibility_fixes"])) + def test_submit_tool_exposes_a_structured_shared_revolve_axis(self) -> None: tool = next(item for item in autonomous_tools() if item["function"]["name"] == "submit_cdsl_fragment") axis = tool["function"]["parameters"]["properties"]["shared_revolve_axis"] @@ -765,6 +1351,288 @@ class AutonomousFragmentTests(unittest.TestCase): self.assertEqual(axis["required"], ["origin_mm", "direction"]) self.assertFalse(axis["additionalProperties"]) + def test_geometry_conclusion_tool_declares_modify_plan_contract(self) -> None: + tool = next(item for item in autonomous_tools() if item["function"]["name"] == "record_geometry_conclusion") + parameters = tool["function"]["parameters"] + plan = parameters["properties"]["optimization_plan"] + + self.assertEqual(plan["properties"]["action"]["minLength"], 1) + self.assertIn("skip", parameters["properties"]["decision"]["enum"]) + self.assertEqual(plan["anyOf"], [{"required": ["action"]}, {"required": ["next_action"]}, {"required": ["reason"]}]) + self.assertEqual(parameters["allOf"][0]["then"], {"required": ["optimization_plan"]}) + + def test_geometry_conclusion_can_skip_step_satisfied_by_prior_geometry(self) -> None: + async def exercise() -> tuple[dict, dict, list[tuple[str, dict]]]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "skip satisfied step") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen") + state = { + "modeling_plan_enforced": True, + "modeling_plan_status": "approved", + "active_plan_step_id": "step_2", + "modeling_plan": {"steps": [ + {"step_id": "step_1", "status": "complete", "feature_ids": ["base"]}, + {"step_id": "step_2", "status": "pending", "feature_ids": ["bore"]}, + {"step_id": "step_3", "status": "pending", "feature_ids": ["holes"]}, + ]}, + "plan_step_status": {"step_1": "complete", "step_2": "pending", "step_3": "pending"}, + "plan_feature_status": {"base": "complete", "bore": "pending", "holes": "pending"}, + "geometry_rejection": {"working_head": "main:root", "geometry_fingerprint": "root", "conclusion_required": True}, + "geometry_diagnoses_by_fingerprint": {"root": {"observations": {"measure": {"ref": "diag_root_measure"}}}}, + } + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + events, progressed = await runner._execute_tool( + task_id, "skip satisfied step", state, load_engine(current_settings), "record_geometry_conclusion", + {"root_cause": "duplicate_feature", "evidence_refs": ["diag_root_measure"], "decision": "skip", "optimization_plan": {"reason": "prior revolve already contains the bore"}}, + ) + return state, {"progressed": progressed}, events + + state, result, events = asyncio.run(exercise()) + self.assertTrue(result["progressed"]) + self.assertEqual(events[0][1]["decision"], "skip") + self.assertEqual(events[0][1]["planStepId"], "step_2") + self.assertEqual(events[0][1]["nextPlanStepId"], "step_3") + self.assertEqual(state["plan_step_status"]["step_2"], "skipped") + self.assertEqual(state["plan_feature_status"]["bore"], "satisfied_by_prior_step") + self.assertEqual(state["geometry_rejection"], {}) + + def test_geometry_conclusion_tool_enumerates_only_current_evidence_refs(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + runner = AutonomousCdslGenerationRunner(current_settings, WorkspaceStore(current_settings), AsyncMock()) + state = { + "geometry_rejection": { + "working_head": "main:rev_001", + "geometry_fingerprint": "fingerprint", + "conclusion_required": True, + }, + "geometry_diagnoses_by_fingerprint": { + "fingerprint": { + "observations": { + "inspect": {"ref": "diag_fingerprint_inspect"}, + "measure": {"ref": "diag_fingerprint_measure"}, + }, + "conclusion": None, + }, + }, + } + + tools = runner._author_tools( + {"active_revision": "rev_001", "active_branch_id": "main"}, + requirements_frozen=True, + state=state, + ) + conclusion = next(item for item in tools if item["function"]["name"] == "record_geometry_conclusion") + + evidence_items = conclusion["function"]["parameters"]["properties"]["evidence_refs"]["items"] + self.assertEqual(evidence_items["enum"], ["diag_fingerprint_inspect", "diag_fingerprint_measure"]) + self.assertIn("Copy evidence_refs exactly from the enum", conclusion["function"]["description"]) + + def test_verified_current_completion_exposes_direct_plan_skip(self) -> None: + async def exercise() -> tuple[list[str], dict, list[tuple[str, dict]], list[Path]]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "skip redundant verified step") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen") + store.update_task(task_id, { + "revision_id": "rev_001", + "status": "success", + "visibility": "checkpoint", + "branch_id": "main", + }) + store.set_active_revision(task_id, "rev_001", branch_id="main") + state = { + "modeling_plan_enforced": True, + "modeling_plan_status": "approved", + "active_plan_step_id": "step_2", + "active_plan_action_id": "step_2_action_1", + "modeling_plan": {"steps": [ + {"step_id": "step_1", "status": "complete", "feature_ids": [], "actions": []}, + {"step_id": "step_2", "status": "pending", "feature_ids": [], "actions": [ + {"action_id": "step_2_action_1", "required": True, "status": "pending"}, + ]}, + {"step_id": "step_3", "status": "pending", "feature_ids": [], "actions": [ + {"action_id": "step_3_action_1", "required": True, "status": "pending"}, + ]}, + ]}, + "plan_step_status": {"step_1": "complete", "step_2": "pending", "step_3": "pending"}, + "plan_action_status": {"step_2_action_1": "pending", "step_3_action_1": "pending"}, + "completion_ledger": { + "verified_revision": "rev_001", + "items": [{"item": "complete model", "status": "complete", "evidence": "Independent review passed."}], + }, + } + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + loaded = store.read_task(task_id) or {} + tool_names = [ + item["function"]["name"] + for item in runner._author_tools(loaded, requirements_frozen=True, state=state) + ] + events, progressed = await runner._execute_tool( + task_id, + "skip redundant verified step", + state, + load_engine(current_settings), + "skip_satisfied_plan_step", + {"reason": "Current independent completion evidence already proves this step."}, + ) + candidate_files = list(store.task_dir(task_id).glob("candidates/*/candidate.json")) + return tool_names, {"state": state, "progressed": progressed}, events, candidate_files + + tool_names, result, events, candidate_files = asyncio.run(exercise()) + self.assertEqual(tool_names, ["skip_satisfied_plan_step"]) + self.assertTrue(result["progressed"]) + self.assertEqual(result["state"]["plan_step_status"]["step_2"], "skipped") + self.assertEqual(result["state"]["active_plan_step_id"], "step_3") + self.assertEqual(events[0][0], "plan_step_skipped") + self.assertEqual(events[0][1]["evidenceRef"], "completion_ledger:rev_001") + self.assertEqual(candidate_files, []) + + def test_stale_completion_ledger_cannot_skip_plan_step(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + runner = AutonomousCdslGenerationRunner(current_settings, WorkspaceStore(current_settings), AsyncMock()) + state = { + "modeling_plan_enforced": True, + "modeling_plan_status": "approved", + "active_plan_step_id": "step_2", + "modeling_plan": {"steps": [{"step_id": "step_2", "status": "pending", "actions": []}]}, + "plan_step_status": {"step_2": "pending"}, + "completion_ledger": { + "verified_revision": "rev_001", + "items": [{"item": "complete model", "status": "complete", "evidence": "Old review."}], + }, + } + names = { + item["function"]["name"] + for item in runner._author_tools( + {"active_revision": "rev_002", "active_branch_id": "main"}, + requirements_frozen=True, + state=state, + ) + } + + self.assertNotIn("skip_satisfied_plan_step", names) + + def test_incomplete_completion_ledger_cannot_skip_plan_step(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "incomplete completion proof") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen") + store.write_completion_checklist(task_id, "- [ ] base exists\n- [ ] bore exists") + store.update_task(task_id, { + "revision_id": "rev_001", "status": "success", "visibility": "checkpoint", "branch_id": "main", + }) + store.set_active_revision(task_id, "rev_001", branch_id="main") + state = { + "modeling_plan_enforced": True, + "modeling_plan_status": "approved", + "active_plan_step_id": "step_2", + "modeling_plan": {"steps": [{"step_id": "step_2", "status": "pending", "actions": []}]}, + "plan_step_status": {"step_2": "pending"}, + "completion_ledger": { + "verified_revision": "rev_001", + "items": [{"item": "base exists", "status": "complete", "evidence": "visible"}], + }, + } + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + names = { + item["function"]["name"] + for item in runner._author_tools( + store.read_task(task_id) or {}, requirements_frozen=True, state=state, + ) + } + + self.assertNotIn("skip_satisfied_plan_step", names) + + def test_contract_tool_uses_exact_runtime_operation_enum(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + runner = AutonomousCdslGenerationRunner(settings(Path(temporary)), WorkspaceStore(settings(Path(temporary))), AsyncMock()) + tool = next( + item for item in runner._author_tools( + {"active_revision": "", "active_branch_id": "main"}, requirements_frozen=True, state={} + ) if item["function"]["name"] == "get_cdsl_operation_contract" + ) + schema = tool["function"]["parameters"]["properties"]["atomic_id"] + self.assertIn("revolve_add", schema["enum"]) + self.assertNotIn("extrude_cut", schema["enum"]) + + def test_repeated_active_operation_contract_is_not_progress(self) -> None: + async def exercise() -> tuple[bool, bool, list[str], list[str]]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "do not repeat the active contract") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen requirements") + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + state: dict = {} + engine = load_engine(current_settings) + + _, first_progressed = await runner._execute_tool( + task_id, + "do not repeat the active contract", + state, + engine, + "get_cdsl_operation_contract", + {"atomic_id": "extrude_add_blind"}, + ) + state["candidate_action_required"] = { + "working_head": "main:root", + "reason": "engine_geometry_invalid", + } + contract_tool = next( + item for item in runner._author_tools( + store.read_task(task_id) or {}, requirements_frozen=True, state=state, + ) if item["function"]["name"] == "get_cdsl_operation_contract" + ) + selectable = contract_tool["function"]["parameters"]["properties"]["atomic_id"]["enum"] + _, repeated_progressed = await runner._execute_tool( + task_id, + "do not repeat the active contract", + state, + engine, + "get_cdsl_operation_contract", + {"atomic_id": "extrude_add_blind"}, + ) + event_kinds = [str(item.get("kind") or "") for item in state.get("recent_events") or ()] + return first_progressed, repeated_progressed, selectable, event_kinds + + first_progressed, repeated_progressed, selectable, event_kinds = asyncio.run(exercise()) + self.assertTrue(first_progressed) + self.assertFalse(repeated_progressed) + self.assertNotIn("extrude_add_blind", selectable) + self.assertTrue(selectable) + self.assertEqual(event_kinds[-1], "operation_contract_repeated") + + def test_root_rollback_is_blocked_when_best_checkpoint_exists(self) -> None: + async def exercise() -> str: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "protect checkpoint") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen") + store.update_task(task_id, {"revision_id": "rev_001", "parent_revision_id": "", "status": "success", "branch_id": "main"}) + store.update_task(task_id, {"revision_id": "rev_002", "parent_revision_id": "rev_001", "status": "success", "branch_id": "main"}) + store.set_active_revision(task_id, "rev_002", branch_id="main") + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + state = {"best_known_checkpoint": "rev_002"} + events, _ = await runner._execute_tool( + task_id, "protect checkpoint", state, load_engine(current_settings), "rollback_checkpoint", + {"checkpoint_token": "root", "reason": "retry the model"}, + ) + return events[0][1]["message"] + + self.assertIn("ROLLBACK_WOULD_DISCARD_VALID_PROGRESS", asyncio.run(exercise())) + def test_conflicting_revolve_axis_aliases_are_not_guessed(self) -> None: with tempfile.TemporaryDirectory() as temporary: engine = load_engine(settings(Path(temporary))) @@ -824,6 +1692,87 @@ class AutonomousFragmentTests(unittest.TestCase): self.assertNotIn("start_mm", json.dumps(prompt_tokens)) self.assertNotIn("end_mm", json.dumps(prompt_tokens)) + def test_current_topology_token_bank_survives_event_trimming_and_expires_with_snapshot(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "topology token bank") + task_id = str(task["task_id"]) + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + topology = { + "snapshot_id": f"{task_id}/main:root", + "records": [{ + "record_id": "face:top", + "kind": "face", + "feature_id": "base", + "owner_feature_ids": ["base"], + "geometry": {"center_mm": [0, 0, 4], "normal": [0, 0, 1]}, + "executable": True, + }], + } + bank = autonomous_candidate_prompt_tokens(autonomous_selector_tokens(topology)) + runner._artifact_data = lambda _task_id, _task: ( # type: ignore[method-assign] + {"geometry": {"sketches": []}, "features": []}, topology, {}, "main:root" + ) + state = { + "topology_observation": { + "working_head": "main:root", + "snapshot_id": topology["snapshot_id"], + "tokens": bank, + }, + "recent_events": [ + {"kind": "inspect_topology", "result": {"tokens": bank}}, + {"kind": "get_cdsl_operation_contract", "result": {"atomic_id": "hole_blind"}}, + ], + } + + context = json.loads(runner._prompt_messages(task_id, state, load_engine(current_settings))[-1]["content"]) + self.assertEqual(context["current_topology_tokens"]["tokens"], bank) + self.assertEqual(context["current_topology_tokens"]["snapshot_id"], topology["snapshot_id"]) + + topology["snapshot_id"] = f"{task_id}/main:changed" + stale_context = json.loads(runner._prompt_messages(task_id, state, load_engine(current_settings))[-1]["content"]) + self.assertIsNone(stale_context["current_topology_tokens"]) + + def test_submit_rejects_a_token_outside_the_current_token_bank(self) -> None: + async def exercise() -> list[tuple[str, dict]]: + with tempfile.TemporaryDirectory() as temporary: + current_settings = settings(Path(temporary)) + store = WorkspaceStore(current_settings) + task = store.ensure_task(None, "token bank submit gate") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen\nBuild a plate.") + runner = AutonomousCdslGenerationRunner(current_settings, store, AsyncMock()) + topology = { + "snapshot_id": f"{task_id}/main:root", + "records": [{ + "record_id": "face:top", "kind": "face", "feature_id": "base", "owner_feature_ids": ["base"], + "geometry": {"center_mm": [0, 0, 4], "normal": [0, 0, 1]}, "executable": True, + }], + } + bank = autonomous_candidate_prompt_tokens(autonomous_selector_tokens(topology)) + runner._artifact_data = lambda _task_id, _task: (None, topology, None, "main:root") # type: ignore[method-assign] + state = { + "candidate_attempts_by_head": {}, + "read_operation_contract_ids": ["hole_blind"], + "topology_observation": {"working_head": "main:root", "snapshot_id": topology["snapshot_id"], "tokens": bank}, + } + fragment = { + "feature": { + "atomic_id": "hole_blind", + "params": {"diameter_mm": 4, "depth_mm": 6, "positions": [{"mm": [0, 0, 4]}]}, + "selector_tokens": ["sel_not_in_bank"], + }, + } + events, _ = await runner._execute_tool( + task_id, "token bank submit gate", state, load_engine(current_settings), "submit_cdsl_fragment", + {"batch_goal": "Create one mounting hole on the observed face.", "fragment_json": json.dumps(fragment)}, + ) + return events + + events = asyncio.run(exercise()) + self.assertIn("TOPOLOGY_TOKEN_UNOBSERVED", events[0][1]["message"]) + def test_forged_selector_token_is_rejected_before_candidate_staging(self) -> None: with tempfile.TemporaryDirectory() as temporary: current_settings = settings(Path(temporary)) @@ -834,6 +1783,16 @@ class AutonomousFragmentTests(unittest.TestCase): with self.assertRaisesRegex(AutonomousFragmentError, "TOPOLOGY_TOKEN_INVALID"): materialize_autonomous_fragment(None, fragment, engine=engine, selector_tokens={}, max_features=1) + def test_fragment_selector_token_reader_covers_every_feature(self) -> None: + fragment = { + "features": [ + {"feature": {"atomic_id": "hole_blind", "params": {}, "selector_tokens": ["sel_face"]}}, + {"atomic_id": "fillet", "params": {}, "selector_tokens": ["sel_edge"]}, + ], + } + + self.assertEqual(_fragment_selector_tokens(fragment), ["sel_face", "sel_edge"]) + def test_hole_uses_server_injected_host_face_without_a_sketch(self) -> None: with tempfile.TemporaryDirectory() as temporary: current_settings = settings(Path(temporary)) @@ -906,6 +1865,22 @@ class AutonomousFragmentTests(unittest.TestCase): with self.assertRaisesRegex(AutonomousFragmentError, "selector_tokens"): materialize_autonomous_fragment(None, invalid, engine=engine, selector_tokens={}, max_features=1) + def test_hole_materialization_aggregates_missing_params_aliases_and_host_token(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + engine = load_engine(settings(Path(temporary))) + invalid = { + "feature": { + "atomic_id": "hole_blind", + "params": {"positions_mm": [[0, 0, 0]], "direction": [0, 0, -1]}, + }, + } + + with self.assertRaisesRegex( + AutonomousFragmentError, + r"missing params: diameter_mm, depth_mm, positions; unsupported params: direction, positions_mm; hole_blind requires exactly one face selector token", + ): + materialize_autonomous_fragment(None, invalid, engine=engine, selector_tokens={}, max_features=1) + def test_failed_candidate_build_never_creates_a_revision(self) -> None: with tempfile.TemporaryDirectory() as temporary: current_settings = settings(Path(temporary)) @@ -951,6 +1926,7 @@ class AutonomousToolTests(unittest.TestCase): task_id, request="reject the wrong base", conversation_id="conv_abcdef123456", provider=provider, model=model, initial_messages=[], frozen_attachment_ids=[], fresh=True, ) + state["read_operation_contract_ids"] = ["extrude_add_blind"] rejected_review = {**accepted_plate_batch_review(), "verdict": "reject", "batch_goal_status": "failed", "evidence": ["The plate is visibly the wrong shape."]} with patch("app.services.autonomous_cdsl_generation.render_checkpoint", return_value={"views": []}), patch( "app.services.autonomous_cdsl_generation.review_candidate_batch", @@ -991,6 +1967,7 @@ class AutonomousToolTests(unittest.TestCase): task_id, request="review outage", conversation_id="conv_abcdef123456", provider=provider, model=model, initial_messages=[], frozen_attachment_ids=[], fresh=True, ) + state["read_operation_contract_ids"] = ["extrude_add_blind"] with patch("app.services.autonomous_cdsl_generation.render_checkpoint", return_value={"views": []}), patch( "app.services.autonomous_cdsl_generation.review_candidate_batch", AsyncMock(side_effect=VisualReviewError("review endpoint timed out")), @@ -1255,6 +2232,11 @@ class AutonomousToolTests(unittest.TestCase): ) self.assertFalse(progressed) self.assertIn("EVIDENCE_UNKNOWN", events[0][1]["message"]) + diagnostic = events[0][1]["diagnostic"] + self.assertEqual(diagnostic["code"], "GEOMETRY_CONCLUSION_EVIDENCE_UNKNOWN") + self.assertEqual(diagnostic["stage"], "geometry_diagnosis") + self.assertEqual(diagnostic["path"], "evidence_refs") + self.assertEqual(diagnostic["evidence"]["allowed_evidence_refs"], ["diag_root_inspect"]) events, progressed = await runner._execute_tool( task_id, "", state, engine, "record_geometry_conclusion", @@ -1263,7 +2245,7 @@ class AutonomousToolTests(unittest.TestCase): self.assertTrue(progressed) self.assertEqual(events[0][0], "geometry_conclusion") tools = [item["function"]["name"] for item in runner._author_tools(store.read_task(task_id) or {}, requirements_frozen=True, state=state)] - self.assertEqual(tools, ["submit_cdsl_fragment", "rollback_checkpoint"]) + self.assertEqual(tools, ["get_cdsl_operation_contract", "rollback_checkpoint"]) asyncio.run(exercise()) @@ -1356,7 +2338,7 @@ class AutonomousToolTests(unittest.TestCase): self.assertEqual(state["candidate_action_required"]["reason"], "duplicate_repair_observation") names = [item["function"]["name"] for item in runner._author_tools(task, requirements_frozen=True, state=state)] - self.assertEqual(names, ["submit_cdsl_fragment", "rollback_checkpoint"]) + self.assertEqual(names, ["get_cdsl_operation_contract", "rollback_checkpoint"]) def test_unchanged_candidate_attempts_are_not_compensated(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -1480,7 +2462,8 @@ class AutonomousToolTests(unittest.TestCase): def test_authoring_tools_do_not_request_strict_provider_schema(self) -> None: tools = autonomous_tools() submit = next(tool for tool in tools if tool["function"]["name"] == "submit_cdsl_fragment") - self.assertEqual(submit["function"]["parameters"]["properties"]["fragment_json"]["type"], "string") + self.assertEqual(submit["function"]["parameters"]["properties"]["fragment"]["type"], "object") + self.assertEqual(submit["function"]["parameters"]["properties"]["batch_relationship"]["minLength"], 12) self.assertFalse(any(tool["function"].get("strict") for tool in tools)) self.assertEqual(tools[0]["function"]["name"], "write_requirements_document") @@ -1501,7 +2484,7 @@ class AutonomousToolTests(unittest.TestCase): } self.assertIn("write_requirements_document", first_turn) - self.assertEqual(root_follow_up, {"submit_cdsl_fragment"}) + self.assertEqual(root_follow_up, {"get_cdsl_operation_contract", "submit_cdsl_fragment"}) self.assertNotIn("write_requirements_document", checkpoint_follow_up) self.assertEqual(checkpoint_follow_up, {"inspect_topology", "complete_task"}) self.assertNotIn("inspect_model", checkpoint_follow_up) @@ -1584,6 +2567,7 @@ class AutonomousToolTests(unittest.TestCase): task_id, request="schema preflight", conversation_id="conv_abcdef123456", provider=provider, model=model, initial_messages=[], frozen_attachment_ids=[], fresh=True, ) + state["read_operation_contract_ids"] = ["extrude_add_blind"] invalid = { "sketch": { "workplane": "XY", @@ -1736,6 +2720,8 @@ class AutonomousToolTests(unittest.TestCase): ("write_requirements_document", {"markdown": "# Plate\nUse a 20 x 10 x 4 mm rectangular plate."}), ("write_completion_checklist", {"markdown": completion_checklist()}), ("submit_cdsl_fragment", {"batch_goal": "Create the rectangular base plate.", "fragment_json": json.dumps(base_fragment())}), + ("get_cdsl_operation_contract", {"atomic_id": "extrude_add_blind"}), + ("submit_cdsl_fragment", {"batch_goal": "Create the rectangular base plate.", "fragment_json": json.dumps(base_fragment())}), ("complete_task", {"self_review": "All frozen requirements are satisfied."}), ]) author_tool_names: list[set[str]] = [] @@ -1767,7 +2753,9 @@ class AutonomousToolTests(unittest.TestCase): self.assertTrue(task["revisions"][0]["candidate_review_path"].startswith("revisions/rev_001/reviews/")) self.assertIn("write_requirements_document", author_tool_names[0]) self.assertEqual(author_tool_names[1], {"write_completion_checklist"}) - self.assertEqual(author_tool_names[2], {"submit_cdsl_fragment"}) + self.assertEqual(author_tool_names[2], {"get_cdsl_operation_contract", "submit_cdsl_fragment"}) + self.assertEqual(author_tool_names[3], {"get_cdsl_operation_contract"}) + self.assertEqual(author_tool_names[4], {"submit_cdsl_fragment"}) self.assertTrue(all("write_requirements_document" not in names for names in author_tool_names[1:])) def test_final_warning_keeps_task_running_and_returns_to_author(self) -> None: @@ -1875,6 +2863,8 @@ class AutonomousToolTests(unittest.TestCase): [("write_requirements_document", {"markdown": "# Plate"})], [("write_completion_checklist", {"markdown": completion_checklist()})], [("submit_cdsl_fragment", {"batch_goal": "Create the rectangular base plate.", "fragment_json": json.dumps(base_fragment())})], + [("get_cdsl_operation_contract", {"atomic_id": "extrude_add_blind"})], + [("submit_cdsl_fragment", {"batch_goal": "Create the rectangular base plate.", "fragment_json": json.dumps(base_fragment())})], [("inspect_model", {})], [("complete_task", {"self_review": "done"})], ]) diff --git a/backend/tests/test_plan_actions.py b/backend/tests/test_plan_actions.py new file mode 100644 index 00000000..af38e71a --- /dev/null +++ b/backend/tests/test_plan_actions.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +from app.services.autonomous_cdsl_generation import ( + AutonomousCdslGenerationRunner, + parse_modeling_plan, +) +from app.services.storage import WorkspaceStore +from pathlib import Path +from app.settings import ProviderConfig, ProviderModel, Settings + + +def _plan(): + return parse_modeling_plan( + """ +[STEP step_3] +goal: 两端夹紧槽 +relationship: 左右槽属于同一夹紧结构,但分别作用于左右轴套 +[ACTION left_clamp_slot] +step: step_3 +title: 左轴套夹紧槽 +target: left_hub +operation: extrude_cut_blind +[ACTION right_clamp_slot] +step: step_3 +title: 右轴套夹紧槽 +target: right_hub +operation: extrude_cut_blind +depends_on: left_clamp_slot +""", + checklist=["两端夹紧槽"], + runtime_atomic_ids={"extrude_cut_blind"}, + ) + + +def test_related_targets_stay_in_one_step_with_independent_actions(): + plan = _plan() + assert [item["action_id"] for item in plan["steps"][0]["actions"]] == [ + "left_clamp_slot", + "right_clamp_slot", + ] + assert plan["steps"][0]["relationship"] == "左右槽属于同一夹紧结构,但分别作用于左右轴套" + assert plan["actions"][1]["depends_on"] == ["left_clamp_slot"] + + +def test_soft_action_headings_are_indexed_without_forcing_machine_fields(): + plan = parse_modeling_plan( + "## Step 3 - 两端夹紧槽\n### Action 3a - 左槽\n切左槽\n### Action 3b - 右槽\n切右槽", + checklist=["两端夹紧槽"], + runtime_atomic_ids={"extrude_cut_blind"}, + ) + assert [item["title"] for item in plan["steps"][0]["actions"]] == ["左槽", "右槽"] + assert plan["steps"][0]["actions"][1]["depends_on"] == ["step_1_action_1"] + + +def test_action_progress_advances_within_step_before_next_step(tmp_path): + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) + store = WorkspaceStore(settings) + runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock()) + state = { + "modeling_plan_enforced": True, + "modeling_plan_status": "approved", + "modeling_plan": _plan(), + "active_plan_step_id": "step_3", + "active_plan_action_id": "left_clamp_slot", + "plan_action_status": {"left_clamp_slot": "pending", "right_clamp_slot": "pending"}, + "plan_feature_status": {}, + "plan_step_status": {"step_3": "pending"}, + } + runner._mark_plan_progress(state, { + "plan_step_id": "step_3", + "plan_action_id": "left_clamp_slot", + "atomic_ids": ["extrude_cut_blind"], + }) + assert state["active_plan_step_id"] == "step_3" + assert state["active_plan_action_id"] == "right_clamp_slot" + assert state["plan_action_status"]["left_clamp_slot"] == "complete" + assert state["plan_action_status"]["right_clamp_slot"] == "pending" + assert state["plan_step_status"]["step_3"] == "pending" + + +def test_action_operation_mismatch_is_explicit(): + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + settings = Settings(task_root=Path("/tmp/cdsl-cad-plan-action-test/tasks"), conversation_root=Path("/tmp/cdsl-cad-plan-action-test/conversations"), library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) + store = WorkspaceStore(settings) + runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock()) + state = { + "modeling_plan_enforced": True, + "modeling_plan_status": "approved", + "modeling_plan": _plan(), + "active_plan_step_id": "step_3", + "active_plan_action_id": "left_clamp_slot", + "plan_action_status": {"left_clamp_slot": "pending", "right_clamp_slot": "pending"}, + } + action = runner._plan_current_action(state) + assert action is not None + assert action["operation"] == "extrude_cut_blind" + + +def test_enforced_submit_schema_exposes_action_id(): + from app.services.autonomous_cdsl_generation import _operation_contract_payload + from app.services.cdsl_authoring_schema import operation_contract_hash + from app.services.engine_service import load_engine + + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + root = Path("/tmp/cdsl-cad-plan-action-schema") + settings = Settings(task_root=root / "tasks", conversation_root=root / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "schema") + contract = _operation_contract_payload(load_engine(settings), "extrude_cut_blind") + state = {"modeling_plan_enforced": True, "modeling_plan_mode": "indexed", "active_operation_contract": contract, "pending_operation_revision": "", "pending_operation_contract_hash": operation_contract_hash("extrude_cut_blind", contract["canonical_fragment_schema"])} + tool = AutonomousCdslGenerationRunner(settings, store, AsyncMock())._canonical_submit_tool(task, state) + assert tool is not None + assert "plan_action_id" in tool["function"]["parameters"]["properties"] + assert "plan_action_id" in tool["function"]["parameters"]["required"] + + +def test_multiple_outer_profiles_request_plan_action_split_without_schema_loop(tmp_path): + from app.services.engine_service import load_engine + + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "left and right slots") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen\nCreate left and right slots.") + runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock()) + engine = load_engine(settings) + plan = parse_modeling_plan( + "## Step 1 - Slots\n### Action 1a - Left slot\n### Action 1b - Right slot", + checklist=["left and right slots"], + runtime_atomic_ids={"extrude_cut_blind"}, + ) + state = { + "modeling_plan_enforced": True, + "modeling_plan_status": "approved", + "modeling_plan": plan, + "active_plan_step_id": "step_1", + "active_plan_action_id": "step_1_action_1", + "plan_step_status": {"step_1": "pending"}, + "plan_action_status": {"step_1_action_1": "pending", "step_1_action_2": "pending"}, + "candidate_attempts_by_head": {}, + } + + asyncio.run(runner._execute_tool( + task_id, "left and right slots", state, engine, "get_cdsl_operation_contract", + {"atomic_id": "extrude_cut_blind"}, + )) + fragment = { + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": { + "type": "analytic_contours", + "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [-10, 0], "radius_mm": 2}]}, + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [10, 0], "radius_mm": 2}]}, + ], + }, + }, + "feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 5}}, + } + events, progressed = asyncio.run(runner._execute_tool( + task_id, "left and right slots", state, engine, "submit_cdsl_fragment", + { + "plan_step_id": "step_1", + "plan_action_id": "step_1_action_1", + "batch_goal": "Create both slots.", + "fragment": fragment, + }, + )) + + assert progressed is False + assert events[0][1]["diagnostic"]["code"] == "CDSL_PROFILE_MULTIPLE_OUTERS" + assert state["modeling_plan_status"] == "revise" + assert state["candidate_action_required"]["reason"] == "plan_action_split_required" + assert state["format_correction"] == {} + assert not state.get("schema_retry_counts") + assert (store.read_task(task_id) or {}).get("active_revision") == "" + + +def test_reviewer_action_count_can_require_plan_revision_without_server_semantic_rules(tmp_path): + from app.services.engine_service import load_engine + + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("backend/engine/cdsl_engine"), llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, default_provider_id="test", providers=(provider,)) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "left and right slots") + task_id = str(task["task_id"]) + store.write_requirements_document(task_id, "# Frozen\nCreate left and right slots.") + store.write_completion_checklist(task_id, "- [ ] left and right slots") + runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock()) + state = { + "modeling_plan_enforced": True, + "modeling_plan_status": "missing", + "modeling_plan_review_attempts": 0, + } + reviewer_revise = { + "schema_version": "cad.modeling-plan-review.v1", + "verdict": "revise", + "confidence": 0.9, + "issues": [{"type": "action_granularity", "step_id": "step_1", "message": "The two independently located slots need separate actions."}], + "coverage": [{"requirement": "left and right slots", "step_id": "step_1", "status": "covered", "evidence": "The step names both slots."}], + "step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The related slots remain together."}], + "action_checks": [{"step_id": "step_1", "status": "fail", "notes": "The plan has one action but requires two.", "required_action_count": 2}], + } + with patch("app.services.autonomous_cdsl_generation.review_modeling_plan", AsyncMock(return_value=reviewer_revise)): + events, progressed = asyncio.run(runner._execute_tool( + task_id, + "left and right slots", + state, + load_engine(settings), + "write_modeling_plan", + {"plan_text": "## Step 1 - Clamp slots\nCreate the left slot and right slot."}, + )) + + assert progressed is True + assert state["modeling_plan_status"] == "revise" + assert "action_granularity_issues" not in state["modeling_plan"] + review_event = next(payload for name, payload in events if name == "modeling_plan_review") + assert review_event["review"]["verdict"] == "revise" + assert review_event["review"]["action_checks"][0]["status"] == "fail" + assert [item["function"]["name"] for item in runner._author_tools(store.read_task(task_id) or {}, requirements_frozen=True, state=state)] == ["write_modeling_plan"] diff --git a/backend/tests/test_sphere_add.py b/backend/tests/test_sphere_add.py index 948c22eb..624cb161 100644 --- a/backend/tests/test_sphere_add.py +++ b/backend/tests/test_sphere_add.py @@ -22,24 +22,13 @@ class SphereAddTests(unittest.TestCase): "kind": "part", "part_id": "sphere_test", "meta": {"unit": "mm"}, - "geometry": { - "sketches": [{ - "id": "sphere_locator", - "workplane": { - "origin_mm": [0, 0, 0], - "x_dir": [1, 0, 0], - "normal": [0, 0, 1], - }, - "profile": {"type": "circle", "radius_mm": 1.0}, - }], - }, + "geometry": {"sketches": []}, "features": [{ "id": "sphere", "atomic_id": "sphere_add", "depends_on": [], "name": "Test sphere", "params": {"radius_mm": 2.5, "center_mm": [3.0, -4.0, 5.0]}, - "sketch_id": "sphere_locator", }], } diff --git a/backend/tests/test_visual_review.py b/backend/tests/test_visual_review.py index 46ade106..13d75842 100644 --- a/backend/tests/test_visual_review.py +++ b/backend/tests/test_visual_review.py @@ -15,7 +15,7 @@ import httpx ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) -from app.services.visual_review import VisualReviewError, review_candidate_batch, review_checkpoint # noqa: E402 +from app.services.visual_review import VisualReviewError, review_candidate_batch, review_checkpoint, review_modeling_plan, _unsupported_operation_mentions # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 @@ -109,6 +109,99 @@ def _candidate_response(*, verdict: str = "accept", batch_goal_status: str = "ac class VisualReviewCompatibilityTests(unittest.TestCase): + def test_plan_review_detects_unsupported_operation_mentions(self) -> None: + result = { + "issues": [{"type": "guidance", "step_id": "step_1", "message": "Prefer extrude_add; pattern_circular is unavailable."}], + "coverage": [], + "step_checks": [], + "action_checks": [], + } + self.assertEqual( + _unsupported_operation_mentions(result, [{"atomic_id": "extrude_add_blind"}, {"atomic_id": "extrude_add_two_sided"}]), + ["extrude_add", "pattern_circular"], + ) + self.assertEqual( + _unsupported_operation_mentions( + {"issues": [{"message": "A cut can use extrude_cut."}], "coverage": [], "step_checks": [], "action_checks": []}, + [{"atomic_id": "extrude_cut_blind"}], + ), + ["extrude_cut"], + ) + + def test_plan_review_records_unsupported_operation_without_rejecting_semantic_plan(self) -> None: + arguments = { + "verdict": "pass", + "confidence": 0.9, + "issues": [{"type": "guidance", "step_id": "step_1", "message": "Prefer extrude_add for the boss."}], + "coverage": [{"requirement": "one connected plate", "step_id": "step_1", "status": "covered", "evidence": "The base is described."}], + "step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The step is ordered."}], + "action_checks": [{"step_id": "step_1", "status": "pass", "notes": "One base feature is one action.", "required_action_count": 1}], + } + response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": { + "name": "review_modeling_plan", "arguments": json.dumps(arguments), + }}]}}]}) + with tempfile.TemporaryDirectory() as temporary: + with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response])): + result = asyncio.run(review_modeling_plan( + settings(Path(temporary)), + source_requirements="Build a plate.", + requirements="# Frozen", + checklist=["one connected plate"], + plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "step_1_action_1"}]}]}, + runtime_operations=[{"atomic_id": "extrude_add_blind"}, {"atomic_id": "extrude_add_two_sided"}], + )) + self.assertEqual(result["verdict"], "pass") + self.assertEqual(result["unsupported_operations"], ["extrude_add"]) + self.assertTrue(result["review_warnings"]) + + def test_plan_reviewer_cannot_pass_a_failed_action_check(self) -> None: + arguments = { + "verdict": "pass", + "confidence": 0.9, + "issues": [], + "coverage": [{"requirement": "left and right slots", "step_id": "step_1", "status": "covered", "evidence": "Both are named."}], + "step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The related slots remain in one step."}], + "action_checks": [{"step_id": "step_1", "status": "fail", "notes": "Left and right need separate actions.", "required_action_count": 2}], + } + response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": { + "name": "review_modeling_plan", "arguments": json.dumps(arguments), + }}]}}]}) + with tempfile.TemporaryDirectory() as temporary: + with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response, response])): + with self.assertRaisesRegex(VisualReviewError, "every action check passes"): + asyncio.run(review_modeling_plan( + settings(Path(temporary)), + source_requirements="Cut left and right slots.", + requirements="# Frozen", + checklist=["left and right slots"], + plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "step_1_action_1"}]}]}, + runtime_operations=[{"atomic_id": "extrude_cut_blind"}], + )) + + def test_plan_reviewer_required_action_count_is_checked_against_plan_structure(self) -> None: + arguments = { + "verdict": "pass", + "confidence": 0.9, + "issues": [], + "coverage": [{"requirement": "two slots", "step_id": "step_1", "status": "covered", "evidence": "Both targets are described."}], + "step_checks": [{"step_id": "step_1", "status": "pass", "notes": "Ordering is coherent."}], + "action_checks": [{"step_id": "step_1", "status": "pass", "notes": "Two actions are required.", "required_action_count": 2}], + } + response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": { + "name": "review_modeling_plan", "arguments": json.dumps(arguments), + }}]}}]}) + with tempfile.TemporaryDirectory() as temporary: + with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response, response])): + with self.assertRaisesRegex(VisualReviewError, "required_action_count exceeds"): + asyncio.run(review_modeling_plan( + settings(Path(temporary)), + source_requirements="Create two slots.", + requirements="# Frozen", + checklist=["two slots"], + plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "only_action"}]}]}, + runtime_operations=[{"atomic_id": "extrude_cut_blind"}], + )) + def _review(self, root: Path, client: _Client, *, source_requirements: str = "") -> dict[str, object]: image = root / "iso.png" image.write_bytes(b"png") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 661ff7b2..17876abd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-collapsible": "^1.1.20", "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-slider": "^1.4.7", + "@types/react-syntax-highlighter": "^15.5.13", "ai": "7.0.37", "animejs": "^4.5.0", "clsx": "^2.1.1", @@ -21,6 +22,10 @@ "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", + "react-json-view-lite": "^2.5.0", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "three": "0.160.0", "three-mesh-bvh": "^0.8.0" @@ -3279,6 +3284,54 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.43.tgz", @@ -3289,11 +3342,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmmirror.com/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -3311,6 +3369,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmmirror.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -3334,6 +3401,12 @@ "meshoptimizer": "~1.1.1" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz", @@ -3341,6 +3414,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@vercel/oidc": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.2.0.tgz", @@ -3469,6 +3548,16 @@ } } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.15", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", @@ -3501,6 +3590,56 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", @@ -3516,13 +3655,52 @@ "node": ">=6" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", @@ -3548,6 +3726,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -3604,6 +3795,28 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/eventsource-parser": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz", @@ -3613,6 +3826,25 @@ "node": ">=18.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", @@ -3620,6 +3852,14 @@ "devOptional": true, "license": "MIT" }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -3651,6 +3891,163 @@ "dev": true, "license": "ISC" }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", @@ -3928,6 +4325,30 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmmirror.com/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lucide-react": { "version": "1.32.0", "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.32.0.tgz", @@ -3947,6 +4368,286 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/meshoptimizer": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz", @@ -3954,6 +4655,575 @@ "devOptional": true, "license": "MIT" }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.1.tgz", @@ -4071,6 +5341,31 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -4134,6 +5429,25 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/radix-ui": { "version": "1.6.7", "resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.6.7.tgz", @@ -4234,6 +5548,45 @@ "react": "^19.2.4" } }, + "node_modules/react-json-view-lite": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz", + "integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -4303,6 +5656,26 @@ } } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmmirror.com/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/react-textarea-autosize": { "version": "8.5.9", "resolved": "https://registry.npmmirror.com/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", @@ -4320,6 +5693,88 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/safe-content-frame": { "version": "0.0.24", "resolved": "https://registry.npmmirror.com/safe-content-frame/-/safe-content-frame-0.0.24.tgz", @@ -4415,6 +5870,48 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -4510,6 +6007,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -4565,6 +6082,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -4671,6 +6275,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", @@ -4709,6 +6341,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 7880825c..358c8740 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-collapsible": "^1.1.20", "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-slider": "^1.4.7", + "@types/react-syntax-highlighter": "^15.5.13", "ai": "7.0.37", "animejs": "^4.5.0", "clsx": "^2.1.1", @@ -23,6 +24,10 @@ "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", + "react-json-view-lite": "^2.5.0", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "three": "0.160.0", "three-mesh-bvh": "^0.8.0" diff --git a/frontend/src/app/api/chat/route.ts b/frontend/src/app/api/chat/route.ts index 46a4ad49..bf7a6e99 100644 --- a/frontend/src/app/api/chat/route.ts +++ b/frontend/src/app/api/chat/route.ts @@ -56,14 +56,31 @@ export async function POST(request: NextRequest) { } const stream = createUIMessageStream({ execute: async ({ writer }) => { - const textId = `assistant_${Date.now()}`; - writer.write({ type: "start", messageId: textId }); - writer.write({ type: "text-start", id: textId }); + const messageId = `assistant_${Date.now()}`; + writer.write({ type: "start", messageId }); + let textPartIndex = 0; + let textId: string | null = null; + let sequence = 0; for await (const item of parseSse(upstream)) { - const chunk = backendEventToUiChunk(item, textId); + if (item.event === "done") continue; + sequence += 1; + if (item.event === "text_delta") { + if (!textId) { + textId = `${messageId}_text_${textPartIndex++}`; + writer.write({ type: "text-start", id: textId }); + } + const chunk = backendEventToUiChunk(item, textId, sequence); + if (chunk) writer.write(chunk); + continue; + } + if (textId) { + writer.write({ type: "text-end", id: textId }); + textId = null; + } + const chunk = backendEventToUiChunk(item, `${messageId}_text_${textPartIndex}`, sequence); if (chunk) writer.write(chunk); } - writer.write({ type: "text-end", id: textId }); + if (textId) writer.write({ type: "text-end", id: textId }); writer.write({ type: "finish", finishReason: "stop" }); }, }); diff --git a/frontend/src/app/api/tasks/[taskId]/route.ts b/frontend/src/app/api/tasks/[taskId]/route.ts index 6f87d35b..83c83a22 100644 --- a/frontend/src/app/api/tasks/[taskId]/route.ts +++ b/frontend/src/app/api/tasks/[taskId]/route.ts @@ -9,3 +9,10 @@ export async function GET(_request: NextRequest, context: { params: Promise<{ ta if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); return NextResponse.json(await response.json()); } + +export async function DELETE(_request: NextRequest, context: { params: Promise<{ taskId: string }> }) { + const { taskId } = await context.params; + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" }); + if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); + return NextResponse.json(await response.json()); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 9e9f01aa..a927898a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -395,17 +395,6 @@ button:disabled { .studio-main { display: flex; min-height: 0; flex: 1; } .agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); } .preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } -.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; } -.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; } -.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } -.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } -.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); } -.generation-status li[data-status="completed"] small { color: var(--ui-success); } -.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); } -.generation-status li[data-status="failed"] small { color: var(--ui-error); } -.generation-status details { margin: 8px 0; border-top: 1px solid var(--ui-border-muted); padding-top: 6px; } -.generation-status summary { cursor: pointer; color: var(--ui-text-strong); } -.generation-status pre { margin: 6px 0 0; max-height: 132px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ui-text-muted); } .agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; } .agent-thread-shell { position: relative; } .spin { animation: ui-spin 900ms linear infinite; } @@ -421,6 +410,7 @@ button:disabled { .assistant-row .message-role svg { color: var(--ui-accent); } .message-content { min-width: 0; color: var(--ui-text); } .message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; } +.message-text .markdown-document { margin-top: 0; border-left: 0; padding: 0; font-size: inherit; } .message-request-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; color: var(--ui-error-text); font-size: 11px; line-height: 1.45; } .message-request-error svg { flex: 0 0 auto; margin-top: 1px; } .thread-empty { color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 2px 0 14px; } @@ -448,7 +438,29 @@ button:disabled { .cad-message-heading { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text); font-size: 12px; font-weight: 600; } .cad-message-heading svg { flex: 0 0 auto; color: var(--ui-accent); } .cad-status-label { color: var(--ui-text-subtle); font-size: 10px; font-weight: 500; text-transform: uppercase; } +.cad-tool-name { overflow: hidden; max-width: 46%; border: 1px solid var(--ui-border-muted); border-radius: 3px; background: var(--ui-control-bg); color: var(--ui-accent-text); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 1px 4px; text-overflow: ellipsis; white-space: nowrap; } .cad-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; } +.cad-event-details { margin: 4px 0 0 21px; color: var(--ui-text-subtle); font-size: 10px; } +.cad-event-details summary { cursor: pointer; width: fit-content; } +.cad-event-details pre { max-height: 180px; overflow: auto; margin: 4px 0 0; border-left: 2px solid var(--ui-border-muted); padding-left: 8px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ui-text-muted); font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } +.cad-event-details ul { display: grid; gap: 3px; margin: 4px 0 0; padding-left: 14px; color: var(--ui-text-muted); line-height: 1.4; } +.cad-document-details { margin-top: 7px; } +.markdown-document { margin-top: 7px; border-left: 2px solid var(--ui-accent-border); padding: 2px 0 2px 10px; color: var(--ui-text); font-size: 11px; line-height: 1.65; } +.markdown-document > :first-child { margin-top: 0; }.markdown-document > :last-child { margin-bottom: 0; } +.markdown-document h1, .markdown-document h2, .markdown-document h3 { margin: 12px 0 5px; color: var(--ui-text-strong); line-height: 1.3; } +.markdown-document h1 { font-size: 14px; }.markdown-document h2 { font-size: 13px; }.markdown-document h3 { font-size: 12px; } +.markdown-document p { margin: 5px 0; }.markdown-document ul, .markdown-document ol { display: block; margin: 5px 0; padding-left: 20px; color: var(--ui-text); } +.markdown-document li { margin: 2px 0; }.markdown-document li::marker { color: var(--ui-accent); } +.markdown-document input[type="checkbox"] { margin: 0 6px 0 0; accent-color: var(--ui-accent); } +.markdown-document blockquote { margin: 7px 0; border-left: 2px solid var(--ui-border-strong); padding-left: 9px; color: var(--ui-text-muted); } +.markdown-document a { color: var(--ui-link); text-decoration: underline; text-underline-offset: 2px; } +.markdown-document table { width: 100%; margin: 7px 0; border-collapse: collapse; font-size: 10px; }.markdown-document th, .markdown-document td { border: 1px solid var(--ui-border); padding: 5px 7px; text-align: left; }.markdown-document th { background: var(--ui-panel-raised); color: var(--ui-text-strong); } +.inline-code { border: 1px solid var(--ui-border-muted); border-radius: 3px; background: var(--ui-control-bg); color: var(--ui-accent-text); padding: 1px 4px; font: 0.92em/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.code-viewer { position: relative; max-width: 100%; margin: 7px 0; overflow: auto; border: 1px solid var(--ui-border-strong); border-radius: 4px; background: #171a1d; color: #e8e8e3; } +.code-viewer pre { max-height: 320px; margin: 0 !important; border: 0; white-space: pre; font-size: 10px; line-height: 1.5; } +.code-language { position: sticky; left: 100%; top: 0; z-index: 1; display: block; width: max-content; margin: 5px 6px -18px auto; color: #969b9f; font-size: 9px; text-transform: uppercase; } +.json-tree { max-height: 260px; overflow: auto; margin-top: 6px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); padding: 7px; color: var(--ui-text); font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; } +.json-tree .json-view--property { color: var(--ui-accent-text); }.json-tree .json-view--string { color: var(--ui-success-text); }.json-tree .json-view--number, .json-tree .json-view--boolean { color: var(--ui-secondary-text); } .cad-progress.is-error .cad-message-heading, .cad-progress.is-error .cad-message-heading svg, .cad-error .cad-message-heading, .cad-error .cad-message-heading svg { color: var(--ui-error-text); } .cad-result { margin-top: 12px; } .cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 53ce81ab..d471b8ea 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -165,6 +165,30 @@ export function AgentStudio() { if (error.stage === "generation") setTaskRunning(false); }, []); + const handleCancel = useCallback(() => { + setTaskRunning(false); + setLastError("正在停止 CAD 任务..."); + void (async () => { + let taskId = selectedTaskId; + if (!taskId && conversationId) { + const conversationResponse = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { cache: "no-store" }); + if (conversationResponse.ok) { + const conversation = await conversationResponse.json() as ConversationRecord; + taskId = conversation.current_task_id || ""; + } + } + if (!taskId) throw new Error("尚未取得运行中的任务编号,请稍后重试"); + const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})) as { error?: string }; + throw new Error(payload.error || "停止 CAD 任务失败"); + } + setLastError("CAD 任务已停止"); + })().catch((error) => { + setLastError(error instanceof Error ? error.message : "停止 CAD 任务失败"); + }); + }, [conversationId, selectedTaskId]); + const handleUpload = useCallback(async (files: FileList | null) => { const selectedFiles = Array.from(files || []); if (!selectedFiles.length) return; @@ -247,6 +271,7 @@ export function AgentStudio() { uploading={uploading} uploadError={uploadError} onUpload={handleUpload} + onCancel={handleCancel} theme={theme} onToggleTheme={toggleTheme} providerId={providerId} @@ -257,7 +282,6 @@ export function AgentStudio() { onCadError={handleError} onSelectionChange={setViewerSelection} taskRunning={taskRunning} - taskRecord={taskRecord} /> ); @@ -478,6 +502,7 @@ function StudioShell({ uploading, uploadError, onUpload, + onCancel, theme, onToggleTheme, providerId, @@ -488,7 +513,6 @@ function StudioShell({ onCadError, onSelectionChange, taskRunning, - taskRecord, }: { config: BackendConfig | null; cadResult: CadResult | null; @@ -497,6 +521,7 @@ function StudioShell({ uploading: boolean; uploadError: string; onUpload: (files: FileList | null) => void; + onCancel: () => void; theme: "light" | "dark"; onToggleTheme: () => void; providerId: string; @@ -507,7 +532,6 @@ function StudioShell({ onCadError: (error: CadError) => void; onSelectionChange: (selection: ViewerSelectionContext | null) => void; taskRunning: boolean; - taskRecord: TaskRecord | null; }) { const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const provider = config?.providers.find((item) => item.id === providerId); @@ -531,9 +555,8 @@ function StudioShell({ {!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} {config?.autonomous_generation && !config.review_configured ?
最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}
: null}
- +
-
@@ -541,24 +564,6 @@ function StudioShell({ ); } -function GenerationStatus({ task }: { task: TaskRecord | null }) { - if (task?.lifecycle !== "running") return null; - const agent = task.agent_state; - const events = agent?.recent_events || []; - return ( - - ); -} - function StudioLoading() { return (
diff --git a/frontend/src/components/agent-thread.tsx b/frontend/src/components/agent-thread.tsx index 91dd1c2d..c1f3e2f5 100644 --- a/frontend/src/components/agent-thread.tsx +++ b/frontend/src/components/agent-thread.tsx @@ -2,16 +2,17 @@ import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react"; import { useRef, useState, type ChangeEvent, type DragEvent } from "react"; -import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react"; +import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react"; import type { CadAttachment } from "@/lib/cad-types"; import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; -export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: { +export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload, onCancel }: { attachments: CadAttachment[]; uploading: boolean; uploadError: string; taskRunning?: boolean; onUpload: (files: FileList | null) => void; + onCancel?: () => void; }) { const fileInput = useRef(null); const dragDepth = useRef(0); @@ -65,7 +66,7 @@ export function AgentThread({ attachments, uploading, uploadError, taskRunning =
- + {isDraggingFiles ?
拖放文件上传
: null} @@ -104,8 +105,14 @@ function AssistantMessage() { ); } -function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) { - const running = useAuiState((state) => state.thread.isRunning) || taskRunning; +function Composer({ fileInput, uploading, taskRunning = false, onUpload, onCancel }: { fileInput: React.RefObject; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void; onCancel?: () => void }) { + const aui = useAui(); + const chatRunning = useAuiState((state) => state.thread.isRunning); + const running = chatRunning || taskRunning; + const handleCancel = () => { + onCancel?.(); + if (chatRunning) aui.thread().cancelRun(); + }; const handleFileChange = (event: ChangeEvent) => { if (event.currentTarget.files?.length) onUpload(event.currentTarget.files); event.currentTarget.value = ""; @@ -119,7 +126,7 @@ function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fil
{running ? ( - + ) : ( <> diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index d20026c0..bc152254 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -1,32 +1,48 @@ "use client"; -import { AlertTriangle, Box, Check, Download, Loader2 } from "lucide-react"; +import { AlertTriangle, Box, Check, Download, Eye, FileCheck, Loader2, RotateCcw, Search, Wrench } from "lucide-react"; import { encodeArtifactUrl } from "@/lib/cad-artifacts"; import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; +import { JsonTree, MarkdownDocument } from "./rich-content"; export function TextPart({ text }: { text: string }) { if (!text.trim()) return null; - return

{text}

; + return
{text}
; } export function CadProgressPart({ data }: { data: CadProgress }) { - if (data.step === "agent_stream") return null; const status = String(data.status || "").toLowerCase(); const isRunning = status === "running"; const isError = status === "error"; const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status; + const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError ? AlertTriangle : Check; + const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []); + const documentTitle = data.step === "requirements_document" ? "冻结需求内容" : data.step === "completion_checklist" ? "完成清单内容" : ""; + const documentMarkdown = data.step === "requirements_document" ? withoutRequirementsFilename(data.markdown || "") : data.markdown || ""; return (
- {isRunning ?
{data.message ?
{data.message}
: null} + {documentMarkdown ?
{documentTitle || "文档内容"}{documentMarkdown}
: null} + {data.arguments ?
调用参数
: null} + {data.result !== undefined ?
执行结果
: null} + {evidence.length ?
证据 ({evidence.length})
    {evidence.map((item, index) =>
  • {item}
  • )}
: null}
); } +function withoutRequirementsFilename(markdown: string) { + return markdown + .replace(/^\s*#{1,6}\s*`?requirements\.md`?\s*\n+/i, "") + .replace(/^\s*`?requirements\.md`?\s*\n+/i, "") + .trimStart(); +} + export function CadResultPart({ data }: { data: CadResult }) { const downloads: Array<[string, string]> = data.checkpoint ? [] : [ ["STEP", data.stepPath], diff --git a/frontend/src/components/rich-content.tsx b/frontend/src/components/rich-content.tsx new file mode 100644 index 00000000..b4566ba0 --- /dev/null +++ b/frontend/src/components/rich-content.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { JsonView, collapseAllNested } from "react-json-view-lite"; +import "react-json-view-lite/dist/index.css"; +import { PrismAsync as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; + +export function MarkdownDocument({ children }: { children: string }) { + return ( +
+ {label}, + code: MarkdownCode, + pre: ({ children }) => <>{children}, + }} + > + {children} + +
+ ); +} + +function MarkdownCode({ className, children, ...props }: ComponentPropsWithoutRef<"code"> & { children?: ReactNode }) { + const language = /language-([\w-]+)/.exec(className || "")?.[1]; + const value = String(children || "").replace(/\n$/, ""); + if (!language && !value.includes("\n")) return {children}; + return ; +} + +export function CodeViewer({ code, language = "text" }: { code: string; language?: string }) { + return ( +
+ {language} + + {code} + +
+ ); +} + +export function JsonTree({ data }: { data: unknown }) { + const value = isJsonContainer(data) ? data : { value: data }; + return ( +
+ level < 1 || collapseAllNested(level)} clickToExpandNode /> +
+ ); +} + +function isJsonContainer(value: unknown): value is Record | unknown[] { + return Boolean(value && typeof value === "object"); +} diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index 88cb17ad..6dcb04e3 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -42,6 +42,56 @@ test("maps a rejected independent candidate review into a blocking progress stat }); }); +test("maps a modeling plan revision request into a blocking progress state", () => { + const chunk = backendEventToUiChunk({ + event: "modeling_plan_review", + data: { taskId: "cad_abc", review: { verdict: "revise", issues: [{ message: "split unrelated finish" }] } }, + }, "text_1"); + assert.equal(chunk?.type, "data-cad-progress"); + assert.equal("data" in chunk! ? (chunk.data as { label?: string }).label : null, "计划独立复核"); + assert.equal("data" in chunk! ? (chunk.data as { status?: string }).status : null, "error"); +}); + +test("keeps a server-verified plan skip visible in the timeline", () => { + const chunk = backendEventToUiChunk({ + event: "plan_step_skipped", + data: { + taskId: "cad_abc", + planStepId: "step_6", + nextPlanStepId: "", + evidenceRef: "completion_ledger:rev_008", + status: "success", + message: "The current revision already satisfies this plan step.", + }, + }, "text_1"); + assert.equal(chunk?.type, "data-cad-progress"); + assert.equal("data" in chunk! ? (chunk.data as { label?: string }).label : null, "计划步骤已满足"); + assert.equal("data" in chunk! ? (chunk.data as { status?: string }).status : null, "success"); +}); + +test("gives repeated tool events unique ordered parts", () => { + const first = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "running" } }, "text_1", 4); + const second = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "success" } }, "text_1", 5); + assert.notEqual(first?.id, second?.id); + assert.equal("data" in first! ? (first.data as { sequence?: number }).sequence : null, 4); + assert.equal("data" in second! ? (second.data as { sequence?: number }).sequence : null, 5); +}); + +test("reuses an invocation id so tool completion updates its running card", () => { + const running = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "running" } }, "text_1", 4); + const complete = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "success" } }, "text_1", 5); + assert.equal(running?.id, complete?.id); +}); + +test("keeps frozen requirement markdown visible in the timeline", () => { + const chunk = backendEventToUiChunk({ + event: "requirements_document", + data: { taskId: "cad_abc", status: "frozen", markdown: "# 冻结需求\n\n- 创建底座" }, + }, "text_1", 2); + assert.equal(chunk?.type, "data-cad-progress"); + assert.equal("data" in chunk! ? (chunk.data as { markdown?: string }).markdown : null, "# 冻结需求\n\n- 创建底座"); +}); + test("restores the latest successful task revision for the viewer", () => { const result = latestSuccessfulResult({ task_id: "cad_abc", diff --git a/frontend/src/lib/cad-stream.ts b/frontend/src/lib/cad-stream.ts index 8b0116b2..26cc65b2 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -8,38 +8,58 @@ export type BackendSseEvent = { export function backendEventToUiChunk( item: BackendSseEvent, textId: string, -): UIMessageChunk | null { + sequence = 0, +): (UIMessageChunk & { id?: string }) | null { if (item.event === "text_delta") { return { type: "text-delta", id: textId, delta: String(item.data.text || "") }; } if (item.event === "progress") { return { type: "data-cad-progress", - id: `progress_${String(item.data.step || Date.now())}`, - data: item.data, + id: `progress_${String(item.data.taskId || "task")}_${sequence}`, + data: { ...item.data, sequence }, }; } - if (["requirements_document", "completion_checklist", "completion_audit", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) { + if (["requirements_document", "completion_checklist", "completion_audit", "modeling_plan", "modeling_plan_review", "plan_step_skipped", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) { const review = item.data.review && typeof item.data.review === "object" ? item.data.review as Record : null; const status = item.event === "task_terminal" ? (String(item.data.lifecycle || "") === "failed" ? "error" : "success") - : (item.event === "candidate_review" && String(review?.verdict || "") === "reject") + : (item.event === "modeling_plan_review" && String(review?.verdict || "") === "revise") + || (item.event === "candidate_review" && String(review?.verdict || "") === "reject") || (item.event === "final_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85) ? "error" : String(item.data.status || "running"); + const taskId = String(item.data.taskId || "task"); + const eventId = String(item.data.eventId || `${taskId}_${sequence}_${item.event}`); + const metadata = sequence > 0 || item.data.eventId + ? { + eventId, + sequence, + ...(review ? { review } : {}), + } + : {}; return { type: "data-cad-progress", - id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`, + id: `event_${eventId}`, data: { step: item.event, label: ({ - requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务", - } as Record)[item.event], status, message: String( - item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join(";") : ""), + requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", modeling_plan: "建模计划", modeling_plan_review: "计划独立复核", plan_step_skipped: "计划步骤已满足", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务", + } as Record)[item.event], status, ...metadata, message: String( + item.data.message || item.data.reason + || (review?.evidence instanceof Array ? review.evidence.join(";") : "") + || (review?.issues instanceof Array ? review.issues.map((issue) => typeof issue === "object" && issue ? String((issue as Record).message || "") : String(issue)).filter(Boolean).join(";") : ""), ), - ...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}), + ...(item.data.taskId ? { taskId } : {}), ...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}), ...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}), + ...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}), + ...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}), + ...(item.data.tool ? { tool: String(item.data.tool) } : {}), + ...(item.data.invocationId ? { invocationId: String(item.data.invocationId) } : {}), + ...(item.data.arguments && typeof item.data.arguments === "object" ? { arguments: item.data.arguments as Record } : {}), + ...(item.data.result !== undefined ? { result: item.data.result } : {}), + ...(Array.isArray(item.data.evidence) ? { evidence: item.data.evidence.map(String) } : {}), }, }; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 6385c644..c7768b80 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -4,9 +4,19 @@ export type CadProgress = { step: string; label: string; status: "running" | "success" | "error" | string; + eventId?: string; + sequence?: number; + timestamp?: string; message?: string; + markdown?: string; taskId?: string; nodeId?: string; + tool?: string; + invocationId?: string; + arguments?: Record; + result?: unknown; + evidence?: string[]; + review?: Record; lifecycle?: "running" | "completed" | "failed" | string; attempt?: number; maxAttempts?: number; @@ -96,12 +106,20 @@ export type TaskRecord = { requirements_markdown?: string | null; completion_checklist_path?: string; completion_checklist_markdown?: string | null; + modeling_plan_path?: string; + modeling_plan_review_path?: string; + modeling_plan_version?: number; + modeling_plan_markdown?: string | null; + modeling_plan_review?: Record | null; agent_state?: { no_progress?: number; cycle_tool_calls?: number; last_diagnostic?: string; last_review?: { candidate_id?: string; path?: string; decision?: string; recorded_at?: string }; last_candidate_review?: { verdict?: "accept" | "reject" | string; batch_goal?: string; batch_goal_status?: string; evidence?: string[]; recorded_at?: string }; + modeling_plan_status?: "missing" | "pending_review" | "revise" | "approved" | "stale" | string; + active_plan_step_id?: string; + plan_step_status?: Record; completion_ledger?: { verified_revision?: string; items?: Array<{ item?: string; status?: "complete" | "missing" | "uncertain" | string; evidence?: string }>;