Files
cdsl-cad/backend/app/services/agent_service.py
T
2026-08-26 14:13:11 +08:00

2767 lines
148 KiB
Python

from __future__ import annotations
import asyncio
import base64
from copy import deepcopy
import json
import math
import re
import secrets
import sys
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import httpx
from app.models.contracts import ChatMessage
from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch
from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan
from app.services.incremental_generation import IncrementalGenerationRunner
from app.services.library import CdslLibrary
from app.services.part_skills import PartSkillLibrary
from app.services.quality import FEATURE_RULE_TYPES, QUALITY_RULE_TYPES, validate_verification
from app.services.sse import event
from app.services.storage import WorkspaceStore, now_iso
from app.services.image_observation import (
merge_image_observations,
normalize_image_observation,
normalize_sketch_candidates,
render_image_observation_context,
)
from app.services.image_processing import cv_hints
from app.settings import ProviderConfig, ProviderModel, Settings
class ToolArgumentsError(ValueError):
"""A model returned function-call arguments that are not one JSON object."""
class StrictToolSchemaError(RuntimeError):
"""The selected endpoint rejected an explicitly enabled strict schema."""
class RepeatedToolArgumentsError(RuntimeError):
"""The model failed to emit valid function arguments after a retry."""
def __init__(self, message: str, diagnostic_paths: list[str] | None = None) -> None:
super().__init__(message)
self.diagnostic_paths = diagnostic_paths or []
class CdslRepairLimitError(RuntimeError):
"""The direct CDSL repair budget is exhausted for this request."""
def __init__(self, diagnostic_paths: list[str]) -> None:
super().__init__("CDSL repair limit reached")
self.diagnostic_paths = diagnostic_paths
def get_repair_step_key(planning_state: dict[str, Any], tool_name: str) -> str:
"""Identify one semantic generation step without carrying failures across batches."""
plan = planning_state.get("feature_plan") if isinstance(planning_state, dict) else None
if isinstance(plan, dict):
active_nodes = sorted(
str(node.get("id"))
for node in plan.get("nodes") or ()
if isinstance(node, dict)
and node.get("status") in {"ready", "executing"}
)
if active_nodes:
return f"plan:{str(plan.get('plan_id') or '')}:{','.join(active_nodes)}"
# ``generate_cdsl_model`` and ``patch_cdsl_model`` are both attempts at
# the same repair phase when no feature plan is available.
return f"phase:{str(planning_state.get('phase') or '')}"
def user_visible_error_message(error: Exception, user_text: str) -> str:
if isinstance(error, StrictToolSchemaError) and any(
"\u4e00" <= char <= "\u9fff" for char in str(user_text or "")
):
return (
"所选模型不支持严格 CDSL 工具 schema。请在 backend/.env 中关闭该供应商的 "
"CDSL_*_STRICT_TOOL_SCHEMA 或 CDSL_*_STRICT_TOOL_MODELS,或者改用已验证支持严格函数 schema 的模型。"
)
if isinstance(error, RepeatedToolArgumentsError) and any(
"\u4e00" <= char <= "\u9fff" for char in str(user_text or "")
):
diagnostics = ""
if error.diagnostic_paths:
diagnostics = " 原始工具参数和停止原因已保存到:" + "、".join(error.diagnostic_paths) + "。"
return (
"模型连续两次未返回完整的 CDSL 工具 JSON,已停止重试且未创建模型。"
"请检查所选模型的函数调用兼容性;若仍出现此错误,请关闭该模型的严格工具 schema 开关后再试。"
+ diagnostics
)
if isinstance(error, CdslRepairLimitError):
diagnostics = "、".join(error.diagnostic_paths)
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
return "同一 CDSL 步骤连续重试达到四次上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。"
return "The same CDSL step failed four consecutive times. Diagnostics were saved to: " + diagnostics + "."
return str(error)
def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_end: int) -> dict[str, Any] | None:
"""Recover one known provider defect without accepting arbitrary malformed JSON."""
if (
not isinstance(parsed_value, dict)
or set(parsed_value) != {"cdsl"}
or parsed_end < 1
or source[parsed_end - 1] != "}"
):
return None
# Some OpenAI-compatible endpoints close the tool-argument root after
# `cdsl`, then emit `, "summary": ...}` outside it. Re-open exactly that
# wrapper and accept the result only when it is a complete known envelope.
candidate = source[:parsed_end - 1] + source[parsed_end:]
try:
value, candidate_end = json.JSONDecoder().raw_decode(candidate)
except json.JSONDecodeError:
return None
if candidate[candidate_end:].strip() or not isinstance(value, dict):
return None
if not set(value).issubset({"cdsl", "summary", "assumptions", "verification"}):
return None
if not isinstance(value.get("cdsl"), dict) or not isinstance(value.get("summary"), str):
return None
if not value["summary"].strip():
return None
if "assumptions" in value and (
not isinstance(value["assumptions"], list)
or not all(isinstance(item, str) for item in value["assumptions"])
):
return None
return value
def _recover_trailing_cdsl_metadata(
source: str,
parsed_value: Any,
parsed_end: int,
) -> dict[str, Any] | None:
"""Accept a complete CDSL envelope followed only by duplicate metadata."""
required = {"cdsl", "summary", "assumptions"}
allowed = required | {"verification"}
if (
not isinstance(parsed_value, dict)
or not required.issubset(parsed_value)
or not set(parsed_value).issubset(allowed)
):
return None
# Some providers continue after a complete root object with a second copy
# of its presentation metadata. Decode that suffix as its own object;
# never use a regex to parse nested JSON. The CDSL payload itself may not
# reappear, so the executable model always comes from the first object.
suffix = source[parsed_end:]
if not suffix.startswith(","):
return None
try:
duplicate, duplicate_end = json.JSONDecoder().raw_decode("{" + suffix[1:])
except json.JSONDecodeError:
return None
if (
duplicate_end != len(suffix)
or not isinstance(duplicate, dict)
or not duplicate
or not set(duplicate).issubset({"summary", "assumptions", "verification"})
):
return None
return parsed_value
def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = False) -> dict[str, Any]:
"""Decode one function-call argument object, with guarded CDSL repairs."""
if raw_arguments is None or raw_arguments == "":
return {}
if not isinstance(raw_arguments, str):
raise ToolArgumentsError("arguments must be a JSON object string")
source = raw_arguments.strip()
if not source:
return {}
try:
value, parsed_end = json.JSONDecoder().raw_decode(source)
except json.JSONDecodeError as error:
raise ToolArgumentsError("arguments are not valid JSON") from error
if source[parsed_end:].strip():
if recover_cdsl_wrapper:
repaired = _repair_premature_tool_wrapper_close(source, value, parsed_end)
if repaired is not None:
return repaired
repaired = _recover_trailing_cdsl_metadata(source, value, parsed_end)
if repaired is not None:
return repaired
raise ToolArgumentsError("arguments contain trailing content after the JSON object")
if not isinstance(value, dict):
raise ToolArgumentsError("arguments must decode to a JSON object")
return value
def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[str, Any]:
return {
"ok": False,
"code": "INVALID_TOOL_ARGUMENTS",
"message": (
f"{name} arguments were rejected: {error}. "
"Regenerate the same tool call with exactly one complete JSON object, "
"from its first `{` through its final `}`. Do not repeat any fields "
"or append prose, Markdown fences, or another JSON value."
),
}
def _cdsl_error_details(error: Exception) -> dict[str, Any]:
message = str(error)
known_codes = (
"FEATURE_PLAN_INVALID", "FEATURE_PLAN_CYCLE", "TOPOLOGY_REQUIRED", "TOPOLOGY_NOT_AVAILABLE",
"TOPOLOGY_SNAPSHOT_STALE", "SELECTOR_CONTEXT_REQUIRED", "SELECTOR_NOT_FOUND",
"SELECTOR_AMBIGUOUS", "SELECTOR_GEOMETRY_MISMATCH", "FEATURE_NOT_READY", "COMPLETED_FEATURE_MUTATION",
"INVALID_CDSL_PATCH",
)
explicit_code = next((code for code in known_codes if code in message), "")
if explicit_code:
if explicit_code == "INVALID_CDSL_PATCH":
return {
"code": explicit_code,
"path": "$",
"kind": "patch",
"repair_instruction": "Read the current CDSL and apply a path that exists in base_revision_id; use generate_cdsl_model for structural changes.",
}
if explicit_code == "FEATURE_NOT_READY":
return {
"code": explicit_code,
"path": "$.features",
"kind": "feature_plan",
"repair_instruction": "Keep completed features unchanged and generate only the listed allowed_feature_ids from the current ready plan batch.",
}
return {
"code": explicit_code,
"path": "$",
"kind": "topology" if "SELECTOR" in explicit_code or "TOPOLOGY" in explicit_code else "feature_plan",
"repair_instruction": "Use the current topology snapshot and a valid ready feature batch, then retry.",
}
path_match = re.search(r"(?:at|path) (\$[^: ]*)", message)
quality = getattr(error, "quality_report", None)
if isinstance(quality, dict):
return {
"code": "VERIFICATION_FAILED",
"path": "$.verification.rules",
"kind": "verification",
"repair_instruction": "Correct the CDSL feature geometry or the verification rule, then submit a local patch or complete replacement.",
"quality": quality,
}
if "schema violation" in message:
return {
"code": "CDSL_SCHEMA_INVALID",
"path": path_match.group(1) if path_match else "$",
"kind": "schema",
"repair_instruction": "Correct the field at the reported JSONPath using the authoritative CDSL schema.",
}
return {
"code": "CDSL_RUNTIME_INVALID",
"path": path_match.group(1) if path_match else "$",
"kind": "runtime",
"repair_instruction": "Correct the invalid CDSL structure, dependency, selector, or runtime parameter and retry.",
}
def invalid_cdsl_result(error: Exception) -> dict[str, Any]:
details = _cdsl_error_details(error)
return {
"ok": False,
**details,
"message": (
f"The submitted CDSL is incomplete or invalid: {error}. "
"Read the authoritative local engine schema, then call generate_cdsl_model "
"again with a complete compatible model."
),
}
def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str:
code = str(result.get("code") or "")
if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED", "FEATURE_NOT_READY", "INVALID_CDSL_PATCH"}:
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。"
return "The CDSL model does not match the engine contract. Asking the model to correct it and retry."
return str(result.get("message") or result.get("summary") or "")
def response_language_instruction(user_text: str) -> str:
"""Make the language requirement concrete for scripts we can identify safely."""
text = str(user_text or "")
chinese = sum("\u4e00" <= char <= "\u9fff" for char in text)
japanese = sum("\u3040" <= char <= "\u30ff" for char in text)
korean = sum("\uac00" <= char <= "\ud7af" for char in text)
if japanese:
language = "Japanese"
elif korean:
language = "Korean"
elif chinese:
language = "Chinese"
else:
language = "the same primary natural language as the latest user message"
return (
"This turn's output language is mandatory: use "
f"{language} for every user-facing natural-language response. "
"Do not use English unless that is the user's primary language."
)
def _cdsl_tool_schema() -> dict[str, Any]:
engine_dir = Path(__file__).resolve().parents[2] / "engine" / "cdsl_engine"
contract_path = engine_dir / "profile_schema.json"
try:
contract = json.loads(contract_path.read_text(encoding="utf-8"))
schema_name = str(contract.get("cdsl_json_schema_file") or "")
if not schema_name or Path(schema_name).name != schema_name:
raise RuntimeError("Local engine contract has no valid CDSL JSON Schema path")
schema = json.loads((engine_dir / schema_name).read_text(encoding="utf-8"))
supported_atomics = {
str(item)
for item in contract.get("runtime_supported_atomic_ids") or ()
if str(item)
}
declared_atomics = set((contract.get("feature_atomic_ids") or {}).keys())
if not supported_atomics or not supported_atomics.issubset(declared_atomics):
raise RuntimeError("Engine capability contract has invalid runtime atomic ids")
engine_parent = str(engine_dir.parent)
if engine_parent not in sys.path:
sys.path.insert(0, engine_parent)
import cdsl_engine
registered_atomics = {str(item) for item in getattr(cdsl_engine, "SUPPORTED_ATOMIC_IDS", ())}
supported_atomics &= registered_atomics
if not supported_atomics:
raise RuntimeError("Engine has no registered atomic executors in common with its capability contract")
feature_atomic_definition = schema.get("$defs", {}).get("feature_atomic_ids")
if not isinstance(feature_atomic_definition, dict):
raise RuntimeError("Local CDSL JSON Schema has no feature atomic definition")
feature_atomic_definition["enum"] = sorted(supported_atomics)
profile_definition = schema.get("$defs", {}).get("profile_type")
supported_profiles = {
str(item)
for item in contract.get("runtime_supported_profiles") or ()
if str(item)
}
supported_profiles &= {str(item) for item in getattr(cdsl_engine, "SHAPE_GENERATORS", ())}
if isinstance(profile_definition, dict) and supported_profiles:
profile_definition["enum"] = sorted(supported_profiles)
return schema
except (OSError, json.JSONDecodeError, AttributeError) as error:
raise RuntimeError("Local CDSL JSON Schema is unavailable or invalid") from error
CDSL_TOOL_SCHEMA = _cdsl_tool_schema()
GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"}
MAX_AGENT_TOOL_ITERATIONS = 16
VERIFICATION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"rules": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1},
"type": {"enum": sorted(QUALITY_RULE_TYPES)},
"feature": {"type": "string", "minLength": 1},
"expected": {
"description": (
"For bbox, use [dx, dy, dz], "
"{min: [x, y, z], max: [x, y, z]}, or "
"{x_min, x_max, y_min, y_max, z_min, z_max}."
),
},
"tolerance": {"type": "number", "minimum": 0},
"severity": {"enum": ["blocking", "warning", "informational"]},
},
"required": ["id", "type", "expected"],
"allOf": [
{
"if": {"properties": {"type": {"enum": sorted(FEATURE_RULE_TYPES)}}},
"then": {"required": ["feature"]},
},
],
"additionalProperties": False,
},
},
},
"required": ["rules"],
"additionalProperties": False,
}
JSON_PATCH_OPERATION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"op": {"enum": ["add", "remove", "replace", "move", "copy", "test"]},
"path": {"type": "string", "pattern": "^/"},
"from": {"type": "string", "pattern": "^/"},
"value": {},
},
"required": ["op", "path"],
"additionalProperties": False,
}
def engine_capability_manifest(settings: Settings) -> dict[str, Any]:
"""Build the compact planner-facing capability contract from engine files."""
load_engine(settings)
try:
profile = json.loads((settings.engine_root / "profile_schema.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"supported_profiles": [], "runtime_atomic_ids": [], "required_params": {}, "unsupported_profiles": []}
atomic_contracts = profile.get("feature_atomic_ids") if isinstance(profile.get("feature_atomic_ids"), dict) else {}
declared_atomics = {str(item) for item in profile.get("runtime_supported_atomic_ids") or atomic_contracts}
registered_atomics = {str(item) for item in getattr(load_engine(settings), "SUPPORTED_ATOMIC_IDS", ())}
supported_atomics = sorted(declared_atomics & registered_atomics)
declared_profiles = {str(item) for item in profile.get("runtime_supported_profiles") or ()}
registered_profiles = {str(item) for item in getattr(load_engine(settings), "SHAPE_GENERATORS", ())}
return {
"supported_profiles": sorted(declared_profiles & registered_profiles),
"runtime_atomic_ids": supported_atomics,
"required_params": {str(key): list(value.get("required_params") or []) for key, value in atomic_contracts.items() if isinstance(value, dict)},
"unsupported_profiles": sorted(str(item) for item in profile.get("unsupported_profiles") or []),
"verification_rule_types": sorted(QUALITY_RULE_TYPES),
}
IMAGE_SEGMENT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"type": {"enum": ["line", "arc", "circle", "polyline", "unknown_curve"]},
"start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
"end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
"center": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
"clockwise": {"type": "boolean"},
"points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "maxItems": 256},
"image_start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
"image_end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"notes": {"type": "string", "maxLength": 300},
},
"required": ["type"],
"additionalProperties": False,
}
IMAGE_PROFILE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 80},
"role": {"type": "string", "maxLength": 40},
"plane_hint": {"type": "string", "maxLength": 120},
"closed": {"type": "boolean"},
"coordinate_space": {"type": "string", "maxLength": 40},
"segments": {"type": "array", "maxItems": 256, "items": IMAGE_SEGMENT_SCHEMA},
"source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"uncertain": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 300}},
"notes": {"type": "string", "maxLength": 300},
},
"required": ["id", "segments"],
"additionalProperties": False,
}
IMAGE_MEASUREMENT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"value_mm": {"type": "number"},
"min_mm": {"type": "number"},
"max_mm": {"type": "number"},
"source": {"enum": ["user", "image", "cv", "assumption"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"evidence": {"type": "string", "maxLength": 300},
"source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}},
},
"required": ["name"],
"additionalProperties": False,
}
IMAGE_OBSERVATION_PROPERTIES: dict[str, Any] = {
"attachment_ids": {"type": "array", "maxItems": 12, "items": {"type": "string"}},
"part_type": {"type": "string", "minLength": 1, "maxLength": 300},
"visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}},
"uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
"views": {"type": "array", "maxItems": 12, "items": {"type": "object", "properties": {
"attachment_id": {"type": "string"}, "view_role": {"type": "string"}, "orientation": {"type": "string"},
"visible_regions": {"type": "array", "items": {"type": "string"}}, "occluded_regions": {"type": "array", "items": {"type": "string"}},
"quality": {"type": "string"}, "scale_reference_id": {"type": "string"}, "confidence": {"type": "number", "minimum": 0, "maximum": 1},
}, "required": ["attachment_id"], "additionalProperties": False}},
"scale_references": {"type": "array", "maxItems": 12, "items": {"type": "object"}},
"overall_geometry": {"type": "object"},
"surfaces": {"type": "array", "maxItems": 24, "items": {"type": "object"}},
"profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA},
"holes": {"type": "array", "maxItems": 64, "items": {"type": "object"}},
"bends": {"type": "array", "maxItems": 16, "items": {"type": "object"}},
"measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA},
"uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
"assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
"cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}},
}
TOOL_SCHEMAS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "analyze_image_reference",
"description": (
"Perform a complete multi-view CAD image survey. Identify every visible plane, bend, "
"outer profile, hole, slot, irregular cutout, scale reference, estimated measurement, "
"occlusion, and uncertainty. Preserve geometry evidence; do not omit an uncertain profile."
),
"parameters": {
"type": "object",
"properties": IMAGE_OBSERVATION_PROPERTIES,
"required": ["part_type", "visible_features", "uncertain_features", "views", "profiles", "measurements", "uncertainties"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "extract_image_sketch_candidates",
"description": "Convert the complete image survey into candidate 2D sketch profiles for outer faces and irregular openings. Keep polyline or unknown curves when line/arc decomposition is uncertain.",
"parameters": {
"type": "object",
"properties": {
"part_type": {"type": "string", "maxLength": 300},
"profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA},
"measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA},
"visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}},
"uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
"uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
"assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
"cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}},
},
"required": ["profiles", "measurements", "uncertainties"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "search_cdsl_library",
"description": "Search the official local CDSL library for similar geometry and feature sequences.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 8}},
"required": ["query"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "read_cdsl_reference",
"description": "Read one official CDSL sample by part_id. Use this before creating geometry based on a reference.",
"parameters": {"type": "object", "properties": {"part_id": {"type": "string"}}, "required": ["part_id"], "additionalProperties": False},
},
},
{
"type": "function",
"function": {
"name": "describe_design_intent",
"description": "Record a concise natural-language CAD plan as reference for this turn's CDSL generation. This plan is not a CAD contract; CDSL remains the only authoritative model.",
"parameters": {
"type": "object",
"properties": {
"plan": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
"required": ["plan", "assumptions"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "read_current_cdsl",
"description": "Read the current task's latest CDSL before making a natural-language revision.",
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
},
{
"type": "function",
"function": {
"name": "generate_cdsl_model",
"description": "Validate and execute a complete parameterized CDSL model. Use only for explicit CAD generation or revision.",
"parameters": {
"type": "object",
"properties": {
"cdsl": CDSL_TOOL_SCHEMA,
"summary": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
"verification": VERIFICATION_SCHEMA,
},
"required": ["cdsl", "summary", "assumptions"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "patch_cdsl_model",
"description": "Apply RFC 6902 JSON Patch operations to one existing CDSL revision, validate and rebuild it as a new revision.",
"parameters": {
"type": "object",
"properties": {
"base_revision_id": {"type": "string", "minLength": 1},
"patches": {"type": "array", "minItems": 1, "maxItems": 32, "items": JSON_PATCH_OPERATION_SCHEMA},
"summary": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
"verification": VERIFICATION_SCHEMA,
},
"required": ["base_revision_id", "patches", "summary", "assumptions"],
"additionalProperties": False,
},
},
},
]
PLANNING_TOOL_SCHEMAS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "plan_feature_tree",
"description": "Validate and store an acyclic semantic feature plan. This is planning data, not executable CDSL; never invent selectors.",
"parameters": {
"type": "object",
"properties": {
"plan_id": {"type": "string", "minLength": 1},
"nodes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1},
"intent": {"type": "string"},
"atomic_id": {"type": "string", "minLength": 1},
"depends_on": {"type": "array", "items": {"type": "string"}},
"requires_topology": {"type": "boolean"},
"topology_query": {"type": "object"},
"cdsl_feature_ids": {"type": "array", "items": {"type": "string"}},
},
"required": ["id", "atomic_id", "depends_on"],
"additionalProperties": False,
},
},
"replan": {
"type": "object",
"properties": {
"replace_nodes": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"reason": {"type": "string", "minLength": 1},
"alternatives": {"type": "array", "items": {"type": "string"}},
},
"required": ["replace_nodes", "reason", "alternatives"],
"additionalProperties": False,
},
},
"required": ["plan_id", "nodes"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "inspect_current_topology",
"description": "Query real executable face/edge/vertex records from the current successful revision. Returned selectors may be copied into CDSL.",
"parameters": {
"type": "object",
"properties": {
"kind": {"enum": ["face", "edge", "vertex", "body", "plane", "axis"]},
"feature_id": {"type": "string"},
"owner_feature_id": {"type": "string"},
"surface_type": {"type": "string"},
"curve_type": {"type": "string"},
"position_hint": {"type": "string"},
"position": {"type": "string"},
"bbox_mm": {"type": "array", "items": {"type": "number"}, "minItems": 6, "maxItems": 6},
"length_range_mm": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
"area_range_mm2": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
"cursor": {"type": "integer", "minimum": 0},
},
"additionalProperties": False,
},
},
},
]
def tools_for_model(
model: ProviderModel,
*,
include_image_analysis: bool = True,
include_image_sketches: bool = False,
image_stage: str | None = None,
) -> list[dict[str, Any]]:
"""Return this model's tool contract without mutating the shared schema."""
tools = deepcopy(TOOL_SCHEMAS + PLANNING_TOOL_SCHEMAS)
if not include_image_analysis:
tools = [
tool
for tool in tools
if tool.get("function", {}).get("name") != "analyze_image_reference"
]
if not include_image_sketches:
tools = [
tool
for tool in tools
if tool.get("function", {}).get("name") != "extract_image_sketch_candidates"
]
if image_stage in {"survey", "sketch"}:
required_name = "analyze_image_reference" if image_stage == "survey" else "extract_image_sketch_candidates"
tools = [tool for tool in tools if tool.get("function", {}).get("name") == required_name]
if not model.strict_tool_schema:
return tools
for tool in tools:
if tool.get("function", {}).get("name") in GENERATION_TOOL_NAMES:
# This flag constrains function arguments only. It has no effect on
# normal assistant text, the user's prompt, or the summary.
tool["function"]["strict"] = True
return tools
def text_from_message(message: ChatMessage) -> str:
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for message in messages[-20:]:
text = text_from_message(message)
if text:
result.append({"role": message.role, "content": text})
return result
def image_attachments(conversation: dict[str, Any]) -> list[dict[str, Any]]:
return [
attachment
for attachment in conversation.get("attachments") or []
if isinstance(attachment, dict) and attachment.get("kind") == "image" and attachment.get("id")
]
def revision_input_attachments(conversation: dict[str, Any]) -> list[dict[str, str | int]]:
"""Snapshot conversation-owned inputs without duplicating their files into a task."""
conversation_id = str(conversation.get("conversation_id") or "")
snapshots: list[dict[str, str | int]] = []
for attachment in conversation.get("attachments") or []:
if not isinstance(attachment, dict) or str(attachment.get("conversation_id") or "") != conversation_id:
continue
attachment_id = str(attachment.get("id") or "")
if not attachment_id:
continue
snapshots.append({
"attachment_id": attachment_id,
"conversation_id": conversation_id,
"name": str(attachment.get("name") or ""),
"kind": str(attachment.get("kind") or ""),
"mime": str(attachment.get("mime") or ""),
"size": int(attachment.get("size") or 0),
"sha256": str(attachment.get("sha256") or ""),
"width": int(attachment.get("width") or 0),
"height": int(attachment.get("height") or 0),
})
return snapshots
def cad_request_instruction(task_id: str, current_task: dict[str, Any] | None) -> str:
revision_id = str((current_task or {}).get("current_revision") or "")
if revision_id:
return f"""
CAD request state:
- Mode: revision.
- Target task: {task_id}; current successful revision: {revision_id}.
- Call read_current_cdsl first, then preserve unrelated features in the complete replacement CDSL.
"""
prior_attempt = f" Task {task_id} has no successful revision and is only a failed/incomplete build attempt." if task_id else ""
return f"""
CAD request state:
- Mode: create.
- There is no current successful CDSL revision.{prior_attempt}
- Do not call read_current_cdsl. Generate a new model after the normal planning workflow.
"""
def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | None:
"""Return the latest complete survey for every currently attached image."""
attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)}
if not attachment_ids:
return None
for message in reversed(conversation.get("messages") or []):
if not isinstance(message, dict):
continue
for part in reversed(message.get("parts") or []):
if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis":
continue
data = part.get("data")
if not isinstance(data, dict):
continue
analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)}
if attachment_ids.issubset(analyzed_ids) and data.get("observationStage", "complete") == "complete":
return data
return None
def image_reference_stage(conversation: dict[str, Any]) -> str:
"""Return the next image-intake stage while retaining legacy analyses."""
attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)}
if not attachment_ids:
return "complete"
for message in reversed(conversation.get("messages") or []):
if not isinstance(message, dict):
continue
for part in reversed(message.get("parts") or []):
if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis":
continue
data = part.get("data")
if not isinstance(data, dict):
continue
analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)}
if not attachment_ids.issubset(analyzed_ids):
continue
stage = str(data.get("observationStage") or "complete")
if stage == "survey":
return "sketch"
if stage in {"sketch", "complete"}:
return "complete"
return "survey"
def image_reference_observation_part(conversation: dict[str, Any], stage: str) -> dict[str, Any] | None:
attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)}
for message in reversed(conversation.get("messages") or []):
if not isinstance(message, dict):
continue
for part in reversed(message.get("parts") or []):
if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis":
continue
data = part.get("data")
if not isinstance(data, dict):
continue
analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)}
if attachment_ids.issubset(analyzed_ids) and str(data.get("observationStage") or "complete") == stage:
return data
return None
def image_reference_instruction(
attachments: list[dict[str, Any]],
analysis: dict[str, Any] | None,
stage: str = "complete",
) -> str:
if not attachments:
return ""
if stage == "survey":
return """
Image-reference intake gate (highest priority for this turn):
- The user has uploaded image references that have not yet been analyzed.
- Your only tool call in this turn must be analyze_image_reference.
- Do not call describe_design_intent, search_cdsl_library, read_cdsl_reference,
read_current_cdsl, or generate_cdsl_model in this turn.
- Build a complete structured multi-view survey: views, surfaces, bends, outer profiles,
holes, irregular openings, scale references, measurements, CV hints, and uncertainties.
- Preserve uncertain profiles as polyline or unknown_curve evidence instead of dropping them.
- Describe only what is visible. Do not present image estimates as user-verified dimensions.
- The backend will present the structured result and provide it back as
visual-reference context for you to continue this same task.
"""
if stage == "sketch":
return """
Image survey is recorded for the current attachments. Your only tool call in this turn
must be extract_image_sketch_candidates. Use the survey and all attached images to
produce outer-face and irregular-opening profiles. Prefer line, arc, and circle segments;
retain polyline or unknown_curve segments when the image does not justify a primitive.
Include source image ids, coordinate evidence, confidence, measurements, and unresolved
parameters. Do not call CAD planning or generation tools yet.
"""
return """
Image-reference analysis already recorded for the current attachments:
{data}
Use this complete survey and the sketch candidates as visual-reference context.
Do not silently discard visible profiles or openings. User-provided dimensions override
image, CV, or assumption estimates; preserve uncertain values as assumptions.
Interpret the full conversation to decide whether to ask a concise question,
make clearly stated approximate assumptions, or continue the ordinary CDSL
workflow. When the user permits or requests estimates, choose coherent values
yourself and record them as assumptions instead of asking again. Respect the
user's tolerance for estimates. Never present an inferred dimension as an exact
measurement from the image.
""".format(data=render_image_observation_context(analysis))
def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]:
def text(value: Any, name: str, limit: int = 300) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"analyze_image_reference requires a non-empty {name}")
return normalized[:limit]
def text_list(value: Any, name: str, maximum: int) -> list[str]:
if not isinstance(value, list) or not value:
raise ValueError(f"analyze_image_reference requires a non-empty {name} array")
return [text(item, name, 240) for item in value[:maximum]]
raw_dimensions = arguments.get("dimension_candidates")
if raw_dimensions is None:
raw_dimensions = []
if not isinstance(raw_dimensions, list):
raise ValueError("analyze_image_reference dimension_candidates must be an array")
dimensions: list[dict[str, str]] = []
used_ids: set[str] = set()
for item in raw_dimensions[:12]:
if not isinstance(item, dict):
raise ValueError("analyze_image_reference dimension_candidates must contain objects")
dimension_id = text(item.get("id"), "dimension_candidates.id", 80)
if dimension_id in used_ids:
continue
used_ids.add(dimension_id)
dimensions.append({
"id": dimension_id,
"label": text(item.get("label"), "dimension_candidates.label", 160),
"reason": text(item.get("reason"), "dimension_candidates.reason", 240),
})
uncertain = arguments.get("uncertain_features")
if not isinstance(uncertain, list):
raise ValueError("analyze_image_reference requires an uncertain_features array")
return {
"part_type": text(arguments.get("part_type"), "part_type"),
"visible_features": text_list(arguments.get("visible_features"), "visible_features", 12),
"uncertain_features": [text(item, "uncertain_features", 240) for item in uncertain[:8]],
"dimension_candidates": dimensions,
}
def image_observation_payload(observation: dict[str, Any], *, stage: str, artifact_path: str = "") -> dict[str, Any]:
"""Map the persisted snake_case observation to the existing UI data part."""
dimensions = [
{
"id": str(item.get("name") or f"measurement_{index}"),
"label": str(item.get("name") or "尺寸"),
"reason": str(item.get("evidence") or "图片或模型估算"),
}
for index, item in enumerate(observation.get("measurements") or [])
if isinstance(item, dict)
]
return {
"observationStage": stage,
"schemaVersion": observation.get("schema_version", "cad.image-observation.v2"),
"attachmentIds": observation.get("attachment_ids") or [],
"partType": observation.get("part_type") or "",
"visibleFeatures": observation.get("visible_features") or [],
"uncertainFeatures": observation.get("uncertain_features") or [],
"dimensionCandidates": dimensions,
"views": observation.get("views") or [],
"scaleReferences": observation.get("scale_references") or [],
"overallGeometry": observation.get("overall_geometry") or {},
"surfaces": observation.get("surfaces") or [],
"profiles": observation.get("profiles") or [],
"holes": observation.get("holes") or [],
"bends": observation.get("bends") or [],
"measurements": observation.get("measurements") or [],
"uncertainties": observation.get("uncertainties") or [],
"assumptions": observation.get("assumptions") or [],
"cvHints": observation.get("cv_hints") or [],
"artifactPath": artifact_path or None,
}
def _viewer_selection_text(value: Any, limit: int = 240) -> str:
return str(value or "").strip()[:limit]
def _viewer_selection_vector(value: Any) -> list[float] | None:
if not isinstance(value, list) or len(value) < 3:
return None
try:
vector = [float(component) for component in value[:3]]
except (TypeError, ValueError):
return None
return vector if all(math.isfinite(component) for component in vector) else None
def _viewer_selection_bbox(value: Any) -> dict[str, list[float]] | None:
if not isinstance(value, dict):
return None
minimum = _viewer_selection_vector(value.get("min"))
maximum = _viewer_selection_vector(value.get("max"))
return {"min": minimum, "max": maximum} if minimum and maximum else None
def _viewer_selection_entity(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
reference_id = _viewer_selection_text(value.get("referenceId"), 120)
if not reference_id:
return None
return {
"referenceId": reference_id,
"selector": _viewer_selection_text(value.get("selector")),
"snapshotId": _viewer_selection_text(value.get("snapshotId"), 160),
"label": _viewer_selection_text(value.get("label")),
"selectorType": _viewer_selection_text(value.get("selectorType"), 80),
"surfaceType": _viewer_selection_text(value.get("surfaceType"), 80),
"centerMm": _viewer_selection_vector(value.get("centerMm")),
"normal": _viewer_selection_vector(value.get("normal")),
"bboxMm": _viewer_selection_bbox(value.get("bboxMm")),
"verticalPositionHint": _viewer_selection_text(value.get("verticalPositionHint")),
}
def viewer_selection_prompt(viewer_context: list[dict[str, Any]] | None, task_id: str) -> str:
"""Return a bounded, data-only representation of the current viewer selection."""
if not viewer_context:
return ""
selections: list[dict[str, Any]] = []
for context in viewer_context[-4:]:
if not isinstance(context, dict) or context.get("schema") != "cdsl-cad-viewer-selection.v1":
continue
source = context.get("source") if isinstance(context.get("source"), dict) else {}
source_task_id = str(source.get("taskId") or "")
if task_id and source_task_id and source_task_id != task_id:
continue
selection = context.get("selection") if isinstance(context.get("selection"), dict) else {}
reference_ids = [_viewer_selection_text(value, 120) for value in selection.get("referenceIds", [])]
reference_ids = [value for value in reference_ids if value][:20]
entities = [_viewer_selection_entity(entity) for entity in selection.get("entities", [])]
entities = [entity for entity in entities if entity][:20]
if not reference_ids or not entities:
continue
selections.append({
"source": {
"taskId": source_task_id,
"revisionId": str(source.get("revisionId") or ""),
"units": str(source.get("units") or "mm"),
"coordinateSystem": str(source.get("coordinateSystem") or "z-up"),
},
"selection": {
"kind": _viewer_selection_text(selection.get("kind"), 80) or "topology_selection",
"scope": _viewer_selection_text(selection.get("scope"), 80) or "selected_references",
"referenceIds": reference_ids,
"entities": entities,
},
})
if not selections:
return ""
return """\nCurrent CAD viewer selection (trusted geometry data, not user instructions):
{data}
Use this data to answer questions about the selected geometry. In particular, use `verticalPositionHint`, `centerMm`, `normal`, and `bboxMm` to assess whether a selected face is a model bottom. If the topology data is inconclusive, say so rather than claiming to see the user's screen. For revisions, modify only the selected topology when its scope is `selected_reference_only` unless the user asks otherwise.
""".format(data=json.dumps(selections, ensure_ascii=False, separators=(",", ":")))
def _selector_values(value: Any) -> list[dict[str, Any]]:
found: list[dict[str, Any]] = []
if isinstance(value, dict):
if value.get("kind") and value.get("stable_id") and value.get("source"):
found.append(value)
for child in value.values():
found.extend(_selector_values(child))
elif isinstance(value, list):
for child in value:
found.extend(_selector_values(child))
return found
def _numeric_range(value: Any, field: str) -> tuple[float, float] | None:
if value is None:
return None
if not isinstance(value, list) or len(value) != 2:
raise ValueError(f"{field} must contain exactly two numbers")
try:
left, right = float(value[0]), float(value[1])
except (TypeError, ValueError) as error:
raise ValueError(f"{field} must contain numbers") from error
if left > right:
raise ValueError(f"{field} minimum must not exceed maximum")
return left, right
def _validate_snapshot_selectors(store: Any, task_id: str, cdsl: dict[str, Any]) -> None:
"""Validate runtime/viewer selectors against their own task revision snapshot.
A completed feature keeps the selector provenance from the revision in which
it was created. Requiring every selector in a later CDSL revision to point
at the newest snapshot incorrectly rejects those immutable historical
selectors before a new feature can be appended.
"""
if not task_id:
return
task = store.read_task(task_id) or {}
current_revision = str(task.get("current_revision") or "")
if not current_revision:
return
topology_path = store.current_topology_path(task_id)
current_snapshot = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
current_snapshot_id = str((current_snapshot or {}).get("snapshot_id") or f"{task_id}/{current_revision}")
snapshots: dict[str, dict[str, Any]] = {current_snapshot_id: current_snapshot or {}}
def snapshot_for_selector(snapshot_id: str) -> dict[str, Any] | None:
if snapshot_id in snapshots:
return snapshots[snapshot_id]
prefix = f"{task_id}/"
if not snapshot_id.startswith(prefix):
return None
revision_id = snapshot_id[len(prefix):]
if not revision_id:
return None
revision = next(
(item for item in task.get("revisions") or ()
if isinstance(item, dict) and str(item.get("revision_id") or "") == revision_id),
None,
)
if not isinstance(revision, dict) or revision.get("status") != "success":
return None
historical_path = store.revision_topology_path(task_id, revision_id)
if historical_path is None or not historical_path.is_file():
return None
try:
historical = json.loads(historical_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
snapshots[snapshot_id] = historical
return historical
for selector in _selector_values(cdsl):
source = str(selector.get("source") or "")
if source not in {"runtime_snapshot", "viewer_selection"}:
continue
snapshot_id = str(selector.get("snapshot_id") or "")
snapshot = snapshot_for_selector(snapshot_id)
if snapshot is None:
raise ValueError("TOPOLOGY_SNAPSHOT_STALE: selector does not belong to a known task revision")
records = {str(item.get("record_id")): item for item in snapshot.get("records") or () if isinstance(item, dict)}
record = records.get(str(selector.get("stable_id") or ""))
if not record or record.get("executable") is False:
raise ValueError("SELECTOR_NOT_FOUND: selector record is not present in the referenced topology snapshot")
if str(selector.get("kind")) != str(record.get("kind")):
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector kind differs from topology record")
owner = selector.get("owner_feature_id")
owners = {str(item) for item in record.get("owner_feature_ids") or ()}
if owner and str(owner) not in owners:
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector owner differs from topology record")
expected = selector.get("geometry") if isinstance(selector.get("geometry"), dict) else {}
actual = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
for key in ("curve_type", "surface_type"):
if key in expected and expected.get(key) != actual.get(key):
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record")
for key in ("center_mm", "normal", "plane_normal", "start_mm", "end_mm"):
if key not in expected:
continue
left, right = expected.get(key), actual.get(key)
if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right):
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record")
try:
mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right))
except (TypeError, ValueError) as error:
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error
if mismatch:
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record")
if "bbox_mm" in expected:
left, right = expected.get("bbox_mm"), actual.get("bbox_mm")
if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right):
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record")
try:
mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right))
except (TypeError, ValueError) as error:
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm is not numeric") from error
if mismatch:
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record")
for key in ("length_mm", "area_mm2"):
if key in expected and key in actual:
try:
expected_value = float(expected[key])
actual_value = float(actual[key])
except (TypeError, ValueError) as error:
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error
if abs(expected_value - actual_value) > max(1e-5, abs(actual_value) * 1e-5):
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record")
def _topology_query_result(store: Any, task_id: str, arguments: dict[str, Any]) -> dict[str, Any]:
if not task_id:
return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "No current CAD task exists."}
task = store.read_task(task_id) or {}
revision_id = str(task.get("current_revision") or "")
path = store.current_topology_path(task_id)
if not revision_id or path is None or not path.is_file():
return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "The current task has no successful topology snapshot."}
snapshot = json.loads(path.read_text(encoding="utf-8"))
records = [record for record in snapshot.get("records") or () if isinstance(record, dict) and record.get("executable", True) is not False]
kind = str(arguments.get("kind") or "")
if kind:
records = [record for record in records if record.get("kind") == kind]
for key in ("feature_id", "owner_feature_id"):
value = str(arguments.get(key) or "")
if value:
records = [record for record in records if record.get("feature_id") == value or value in (record.get("owner_feature_ids") or [])]
for key in ("surface_type", "curve_type"):
value = str(arguments.get(key) or "")
if value:
records = [record for record in records if (record.get("geometry") or {}).get(key) == value]
position_hint = str(arguments.get("position_hint") or arguments.get("position") or "").strip().casefold()
if position_hint:
position_axis = {"top": 2, "upper": 2, "highest": 2, "bottom": 2, "lower": 2, "lowest": 2,
"left": 0, "right": 0, "front": 1, "back": 1}.get(position_hint)
position_values: list[float] = []
if position_axis is not None:
for candidate in records:
candidate_center = (candidate.get("geometry") or {}).get("center_mm")
if isinstance(candidate_center, (list, tuple)) and len(candidate_center) > position_axis:
try:
position_values.append(float(candidate_center[position_axis]))
except (TypeError, ValueError):
pass
target = None
if position_values and position_axis is not None:
target = min(position_values) if position_hint in {"bottom", "lower", "lowest", "left", "front"} else max(position_values)
def matches_position(record: dict[str, Any]) -> bool:
geometry = record.get("geometry") or {}
center = geometry.get("center_mm")
bbox = geometry.get("bbox_mm")
if not isinstance(center, (list, tuple)) or len(center) < 3:
return False
try:
x, y, z = (float(center[index]) for index in range(3))
except (TypeError, ValueError):
return False
if position_hint in {"top", "upper", "highest", "bottom", "lower", "lowest"}:
return target is not None and abs(z - target) <= 1e-5
if position_hint in {"left", "right", "front", "back"}:
axis = {"left": 0, "right": 0, "front": 1, "back": 1}[position_hint]
sign = {"left": -1, "right": 1, "front": 1, "back": -1}[position_hint]
value = x if axis == 0 else y
return target is not None and abs(value - target) <= 1e-5
return str(geometry.get("position_hint") or "").casefold() == position_hint
records = [record for record in records if matches_position(record)]
bbox_filter = arguments.get("bbox_mm")
if bbox_filter is not None:
if isinstance(bbox_filter, dict) and isinstance(bbox_filter.get("min"), list) and isinstance(bbox_filter.get("max"), list):
bbox_filter = [*bbox_filter["min"], *bbox_filter["max"]]
if not isinstance(bbox_filter, list) or len(bbox_filter) != 6:
raise ValueError("bbox_mm must contain exactly six numbers")
try:
query_bbox = tuple(float(value) for value in bbox_filter)
except (TypeError, ValueError) as error:
raise ValueError("bbox_mm must contain numbers") from error
if query_bbox[0] > query_bbox[3] or query_bbox[1] > query_bbox[4] or query_bbox[2] > query_bbox[5]:
raise ValueError("bbox_mm minimums must not exceed maximums")
def intersects(record: dict[str, Any]) -> bool:
actual = (record.get("geometry") or {}).get("bbox_mm")
if not isinstance(actual, (list, tuple)) or len(actual) != 6:
return False
try:
values = tuple(float(value) for value in actual)
except (TypeError, ValueError):
return False
return all(values[index] <= query_bbox[index + 3] and query_bbox[index] <= values[index + 3] for index in range(3))
records = [record for record in records if intersects(record)]
length_range = _numeric_range(arguments.get("length_range_mm"), "length_range_mm")
area_range = _numeric_range(arguments.get("area_range_mm2"), "area_range_mm2")
if length_range:
records = [record for record in records if length_range[0] <= float((record.get("geometry") or {}).get("length_mm", -1)) <= length_range[1]]
if area_range:
records = [record for record in records if area_range[0] <= float((record.get("geometry") or {}).get("area_mm2", -1)) <= area_range[1]]
offset = max(0, int(arguments.get("cursor") or 0))
limit = min(50, max(1, int(arguments.get("limit") or 20)))
selected = records[offset:offset + limit]
output_records = []
for record in selected:
selector = {
"kind": record["kind"],
"stable_id": record["record_id"],
"source": "runtime_snapshot",
"confidence": 1.0,
"snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}",
"geometry": record.get("geometry") or {},
}
owners = record.get("owner_feature_ids") or []
if owners:
selector["owner_feature_id"] = owners[0]
output_records.append({**record, "selector": selector})
return {
"ok": True,
"task_id": task_id,
"revision_id": revision_id,
"snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}",
"records": output_records,
"next_cursor": offset + len(output_records) if offset + len(output_records) < len(records) else None,
}
def _validate_plan_cdsl_transition(store: Any, task_id: str, plan: dict[str, Any] | None, cdsl: dict[str, Any]) -> None:
if not isinstance(plan, dict):
return
current_path = store.current_cdsl_path(task_id) if task_id else None
current = json.loads(current_path.read_text(encoding="utf-8")) if current_path and current_path.is_file() else {}
current_features = {str(item.get("id")): item for item in current.get("features") or () if isinstance(item, dict)}
next_features = {str(item.get("id")): item for item in cdsl.get("features") or () if isinstance(item, dict)}
completed_ids = {
str(feature_id)
for node in plan.get("nodes") or ()
if isinstance(node, dict) and node.get("status") in {"completed", "executed"}
for feature_id in node.get("cdsl_feature_ids") or ()
}
for feature_id in completed_ids:
if feature_id not in next_features or next_features[feature_id] != current_features.get(feature_id):
raise ValueError(f"COMPLETED_FEATURE_MUTATION: completed feature {feature_id} cannot be removed or rewritten")
allowed_ids = {
str(feature_id)
for node in plan.get("nodes") or ()
if isinstance(node, dict) and node.get("status") in {"ready", "executing"}
for feature_id in node.get("cdsl_feature_ids") or ()
}
for feature_id in next_features:
if feature_id not in current_features and feature_id not in allowed_ids:
ready_nodes = [
str(node.get("id"))
for node in plan.get("nodes") or ()
if isinstance(node, dict) and node.get("status") == "ready"
]
allowed = sorted(allowed_ids)
raise ValueError(
f"FEATURE_NOT_READY: feature {feature_id} is not in the current ready plan batch; "
f"allowed_feature_ids={allowed}; ready_nodes={ready_nodes}"
)
def system_prompt(
settings: Settings,
user_text: str,
viewer_context: list[dict[str, Any]] | None = None,
task_id: str = "",
part_skill_context: str = "",
image_reference_context: str = "",
cad_request_context: str = "",
) -> str:
capability_manifest = json.dumps(engine_capability_manifest(settings), ensure_ascii=False, separators=(",", ":"))
skill_path = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "SKILL.md"
skill = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else ""
profile_schema_path = settings.engine_root / "profile_schema.json"
profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else ""
generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string.
- For a multi-stage CAD request, call plan_feature_tree after describe_design_intent. The plan is a DAG of semantic features; never put invented edge/face IDs in it.
- Generate only the current ready feature batch. The server automatically binds a successful build's topology snapshot; call inspect_current_topology to retrieve exact selector candidates before adding topology-dependent features.
- Copy selectors only from inspect_current_topology or a trusted viewer selection. A screenshot is not an exact topology selector source, but its measured image survey and sketch candidates may guide profile geometry.
- Keep completed CDSL features unchanged and use patch_cdsl_model for later feature batches.
- If a selector or kernel failure blocks a node, use plan_feature_tree with `replan` to replace only that node and its downstream subtree.
- For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement.
- Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model.
- For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime.
- The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm.
- Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL.
- Verification types include bbox, overall_length, overall_width, overall_height, overall_diameter, hole_count, hole_diameter, hole_center, through_condition, solid_count, and feature_count. Overall width and height measure the runtime Y and Z bbox dimensions. Feature-scoped rules (hole_count, hole_diameter, hole_center, through_condition) must include the exact CDSL feature ID.
- Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm."""
workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements.
4. For a multi-feature request, call plan_feature_tree and then generate only its ready batch.
5. After each successful build, inspect topology when the plan has waiting topology-dependent nodes, then patch the existing CDSL.
6. On a schema, runtime, selector, or verification failure, repair only the affected feature/subtree.
7. Never claim success unless the final plan is complete and the tool returns a successful CDSL-only STEP and GLB artifact."""
authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities.
Local skill:
{skill}
Authoritative engine schema:
{profile_schema}"""
return f"""You are the CDSL CAD Agent for CDSL CAD Studio.
Language policy:
- Detect the primary natural language of the latest user message.
- Write every user-facing natural-language response in that same language.
- This includes explanations, clarification questions, generation summaries,
assumptions, progress commentary, and tool-result summaries.
- If the user mixes languages, use the language that carries most of the
request. Do not switch to English merely because this instruction, the local
skill, the engine guide, or a tool schema is written in English.
- Preserve technical identifiers exactly as required: CDSL keys, JSON values
that are enums, profile names, tool names, file names, and model IDs may stay
in their original form.
Tool call contract:
- Every function call arguments field must contain exactly one valid JSON object.
- Do not append prose, Markdown code fences, comments, or a second JSON value.
- Call describe_design_intent first with a concise natural-language plan.
- If a tool reports INVALID_TOOL_ARGUMENTS, correct the arguments and call that
tool again. Do not claim that the CAD model was generated.
- If a generation tool reports INVALID_CDSL, correct its plan or complete CDSL
as applicable and call that same tool again. Do not claim success.
{generation_contract}
Workflow limits:
- Do not expose internal planning or "let me" commentary to the user while
using tools. The application shows tool progress separately.
- Keep final user-facing responses operational and concise. When a structured
CAD result has been produced, do not restate its name, files, revision, or
tool progress; reply only when an assumption, limitation, or next decision
needs the user's attention. Otherwise finish without a prose postscript.
- When clarification is essential, ask exactly one direct question that names
the missing dimension or decision. Do not combine it with a tool trace or a
generic progress update.
- Use at most two CDSL-library searches per user request. If neither finds a
useful reference, stop searching and use the available capability contract to either generate
the model or ask one concise clarification question.
- Do not repeatedly search for the same unavailable feature or profile.
{response_language_instruction(user_text)}
{image_reference_context}
{cad_request_context}
You generate executable CAD through complete parameterized CDSL, never raw CAD source code. For new CAD requests:
1. Use the injected part-skill guidance, when present, only to establish the
structural plan, feature dependency order, parameter roles, and reference queries.
2. Call describe_design_intent with a concise textual plan. Ask one concise
user-facing question before CAD generation if essential dimensions are missing.
{workflow}
Precedence is strict: explicit user request, then CDSL schema/runtime, then
part-skill guidance, then CDSL-library examples. Part skills never authorize
build123d source, an unknown atomic/profile, an invented selector, or a free-
coordinate substitute for a capability the runtime cannot express. For an
unsupported requested structure, ask one concise clarification question or
state the blocker rather than fabricating geometry. If a part-family conflict
is injected, preserve the current part unless the user explicitly requests a
whole-part replacement. A primary-family conflict is a hard clarification
stop: ask one concise question and do not call a generation tool until the
user resolves it.
{authoring_context}
Compact Engine Capability Manifest (planner-facing; backend validator remains authoritative):
{capability_manifest}
Injected part-skill context:
{part_skill_context or "No part-family skill guidance was selected for this request."}
{viewer_selection_prompt(viewer_context, task_id)}
"""
def part_skill_root(settings: Settings) -> Path:
return settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
class AgentService:
def __init__(
self,
settings: Settings,
store: WorkspaceStore,
library: CdslLibrary,
part_skill_library: PartSkillLibrary | None = None,
) -> None:
self.settings = settings
self.store = store
self.library = library
self.part_skill_library = part_skill_library or PartSkillLibrary(part_skill_root(settings))
# The generation worker outlives an individual SSE response. The
# durable task lifecycle remains the cross-process source of truth;
# this map only owns live event delivery in the current process.
self._incremental_runs: dict[str, asyncio.Task[None]] = {}
async def resume_running_tasks(self) -> None:
"""Reattach process-local workers to persisted incremental runs.
A browser disconnect is already independent from the worker. This
recovery path additionally prevents an application restart from
stranding a durable task in ``running``. The original frozen provider,
model, messages, and selected part skills are read from the task, not
from mutable conversation state.
"""
if not self.settings.incremental_generation:
return
for task in self.store.running_tasks():
task_id = str(task.get("task_id") or "")
if not task_id or task_id in self._incremental_runs:
continue
context = self.store.read_generation_run_context(task_id)
if not isinstance(context, dict):
self.store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1",
"stage": "recovery",
"message": "The frozen generation context is unavailable after restart",
})
continue
conversation_id = str(context.get("conversation_id") or "")
conversation = self.store.read_conversation(conversation_id) if conversation_id else None
author_messages = context.get("author_messages")
part_skills = context.get("part_skills")
if not isinstance(conversation, dict) or not isinstance(author_messages, list) or not isinstance(part_skills, dict):
self.store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1",
"stage": "recovery",
"message": "The frozen conversation context is invalid after restart",
})
continue
try:
provider, model = self.settings.resolve_model(
str(context.get("provider_id") or ""), str(context.get("model_id") or ""),
)
except ValueError as error:
self.store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1", "stage": "recovery", "message": str(error),
})
continue
assistant_id = str(context.get("assistant_id") or f"assistant_{secrets.token_hex(8)}")
request = str(context.get("request") or task.get("request") or "")
runner = IncrementalGenerationRunner(self.settings, self.store, self._complete)
async def consume(
*, task_id: str = task_id, request: str = request, conversation: dict[str, Any] = conversation,
provider: ProviderConfig = provider, model: ProviderModel = model,
author_messages: list[dict[str, Any]] = author_messages, part_skills: dict[str, Any] = part_skills,
assistant_id: str = assistant_id, runner: IncrementalGenerationRunner = runner,
) -> None:
parts: list[dict[str, Any]] = []
terminal = ""
try:
async for name, payload in runner.run(
task_id=task_id, request=request, conversation=conversation, provider=provider, model=model,
author_messages=author_messages, part_skills=part_skills, already_started=True,
):
if name == "cad_result":
parts.append({"type": "data-cad-result", "data": payload})
elif name == "task_terminal":
terminal = str(payload.get("lifecycle") or "")
if terminal == "failed":
parts.append({"type": "data-cad-error", "data": {
"stage": "generation", "message": str(payload.get("message") or "CAD 增量生成失败。"),
}})
except Exception as error:
self.store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1", "stage": "recovery_worker", "message": str(error),
})
terminal = "failed"
parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}})
finally:
if not parts and terminal == "completed":
parts.append({"type": "text", "text": "CAD 模型已完成。"})
self._persist_assistant(conversation["conversation_id"], assistant_id, parts, task_id)
self._incremental_runs.pop(task_id, None)
self._incremental_runs[task_id] = asyncio.create_task(consume(), name=f"resume-incremental-cdsl-{task_id}")
async def stream(
self,
messages: list[ChatMessage],
conversation_id: str | None,
selected_task_id: str | None,
provider_id: str | None = None,
model_id: str | None = None,
viewer_context: list[dict[str, Any]] | None = None,
) -> AsyncIterator[bytes]:
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
if latest_user is None:
yield event("cad_error", {"stage": "request", "message": "A user message is required."})
yield event("done", {})
return
user_text = text_from_message(latest_user)
conversation = self.store.ensure_conversation(conversation_id)
task_id = str(selected_task_id or conversation.get("current_task_id") or "")
current_task = self.store.read_task(task_id) if task_id else None
assistant_parts: list[dict[str, Any]] = []
assistant_id = f"assistant_{secrets.token_hex(8)}"
error_payload: dict[str, Any] | None = None
if task_id and current_task is None:
error_payload = {"stage": "request", "message": "The selected CAD task no longer exists. Start a new model or select a valid task."}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, "")
yield event("done", {})
return
if task_id and current_task and str(current_task.get("lifecycle") or "") == "running":
# Do not append the attempted turn: the run's request and
# attachments are immutable until a terminal lifecycle state.
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
yield event("done", {})
return
self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id or None)
try:
provider, model = self.settings.resolve_model(provider_id, model_id)
except ValueError as error:
provider = None
model = None
configuration_error = str(error)
else:
configuration_error = ""
if not self.settings.llm_configured or provider is None or model is None:
message = "Agent 尚未配置模型。请设置 CDSL_LLM_BASE_URL、CDSL_LLM_API_KEY 和 CDSL_LLM_MODEL。"
if configuration_error:
message = configuration_error
error_payload = {"stage": "configuration", "message": message}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
yield event("done", {})
return
try:
attachment_message = self._attachment_message(conversation, model)
except ValueError as error:
error_payload = {"stage": "attachment", "message": str(error)}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
yield event("done", {})
return
# The persistent node-by-node orchestrator is opt-in while existing
# installations migrate their review-model and Chromium configuration.
# Once enabled, every new request is frozen and legacy authoring tools
# are not exposed for that run.
if self.settings.incremental_generation:
if not task_id:
created = self.store.ensure_task(None, user_text)
task_id = str(created["task_id"])
current_task = created
self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
author_messages: list[dict[str, Any]] = [{
"role": "system",
"content": (
"You are an incremental CDSL CAD author. The user request is frozen for this run. "
"Missing dimensions must become explicit assumptions. Use only the requested function tool; do not emit prose or raw CAD source."
),
}]
author_messages.extend(messages_for_model(messages))
if attachment_message:
author_messages.append({"role": "user", "content": attachment_message})
runner = IncrementalGenerationRunner(self.settings, self.store, self._complete)
part_skills = self.part_skill_library.audit(self.part_skill_library.select(user_text), {}, [])
# Acquire the durable run lock before scheduling work so a second
# request cannot slip in during the first model call.
self.store.start_generation(task_id, request=user_text)
self.store.write_generation_run_context(task_id, {
"schema_version": "cad.generation-run-context.v1",
"request": user_text,
"conversation_id": conversation["conversation_id"],
"provider_id": provider.id,
"model_id": model.id,
"assistant_id": assistant_id,
"author_messages": author_messages,
"part_skills": part_skills,
})
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
async def consume_incremental_run() -> None:
run_parts: list[dict[str, Any]] = []
terminal = ""
try:
async for name, event_payload in runner.run(
task_id=task_id,
request=user_text,
conversation=conversation,
provider=provider,
model=model,
author_messages=author_messages,
part_skills=part_skills,
already_started=True,
):
if name == "cad_result":
run_parts.append({"type": "data-cad-result", "data": event_payload})
elif name == "task_terminal":
terminal = str(event_payload.get("lifecycle") or "")
if terminal == "failed":
run_parts.append({
"type": "data-cad-error",
"data": {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")},
})
await queue.put((name, event_payload))
except Exception as error: # runner normally converts failures to a terminal event
self.store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1", "message": str(error), "stage": "worker",
})
terminal = "failed"
payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
run_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}})
await queue.put(("task_terminal", payload))
finally:
if not run_parts and terminal == "completed":
run_parts.append({"type": "text", "text": "CAD 模型已完成。"})
self._persist_assistant(conversation["conversation_id"], assistant_id, run_parts, task_id)
self._incremental_runs.pop(task_id, None)
await queue.put(None)
worker = asyncio.create_task(consume_incremental_run(), name=f"incremental-cdsl-{task_id}")
self._incremental_runs[task_id] = worker
while True:
queued = await queue.get()
if queued is None:
break
name, event_payload = queued
if name == "task_terminal" and str(event_payload.get("lifecycle") or "") == "failed":
yield event("cad_error", {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")})
yield event(name, event_payload)
yield event("done", {})
return
image_inputs = image_attachments(conversation)
intake_stage = image_reference_stage(conversation)
recorded_image_analysis = image_reference_analysis(conversation)
partial_observation = image_reference_observation_part(conversation, "survey")
yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"})
references: list[str] = []
library_searches = 0
inherited_skill_ids = self.part_skill_library.inherited_from_task(current_task)
part_skill_selection = self.part_skill_library.select(user_text, inherited_skill_ids)
planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False}
if task_id:
persisted_plan = self.store.read_feature_plan(task_id)
if isinstance(persisted_plan, dict):
planning_state["feature_plan"] = persisted_plan
yield event("progress", {
"step": "select_part_skill",
"label": "选择建模 Skill",
"status": "success",
"message": "已完成建模 Skill 与辅助规则选择。",
})
model_messages: list[dict[str, Any]] = [{
"role": "system",
"content": system_prompt(
self.settings,
user_text,
viewer_context,
task_id,
self.part_skill_library.render_context(part_skill_selection),
image_reference_instruction(
image_inputs,
recorded_image_analysis or partial_observation,
intake_stage,
),
cad_request_instruction(task_id, current_task),
),
}]
model_messages.extend(messages_for_model(messages))
if attachment_message:
model_messages.append({"role": "user", "content": attachment_message})
tools = tools_for_model(
model,
include_image_analysis=intake_stage == "survey",
include_image_sketches=intake_stage == "sketch",
image_stage=intake_stage if intake_stage in {"survey", "sketch"} else None,
)
required_tool_name: str | None = (
"analyze_image_reference" if intake_stage == "survey"
else "extract_image_sketch_candidates" if intake_stage == "sketch"
else None
)
generate_argument_failures = 0
tool_argument_diagnostics: list[str] = []
cdsl_validation_diagnostics: list[str] = []
generation_completed = False
repair_attempts = 0
repair_step_key: str | None = None
last_plan_status: dict[str, Any] | None = None
try:
for iteration in range(MAX_AGENT_TOOL_ITERATIONS):
# Once the model has been told to repair a CDSL step, stop
# before requesting another completion when that same step has
# exhausted its consecutive repair budget.
if (
planning_state.get("phase") == "CDSL_REPAIR"
and required_tool_name in GENERATION_TOOL_NAMES
and repair_attempts >= self.settings.max_repair_attempts
):
raise CdslRepairLimitError(cdsl_validation_diagnostics)
response = await self._complete(model_messages, tools, provider, model, required_tool_name)
response_choice = response["choices"][0]
choice = response_choice["message"]
tool_calls = choice.get("tool_calls") or []
content = str(choice.get("content") or "")
# Tool-call content is implementation planning. It is retained in
# model_messages for the next round but not shown to the user.
if content and not tool_calls and not required_tool_name:
assistant_parts.append({"type": "text", "text": content})
for chunk in self._chunks(content):
yield event("text_delta", {"text": chunk})
if not tool_calls:
if required_tool_name:
model_messages.append(choice)
model_messages.append({
"role": "system",
"content": f"You must now call {required_tool_name} with corrected complete arguments. Do not reply with prose.",
})
continue
break
model_messages.append(choice)
for call in tool_calls:
name = str(call.get("function", {}).get("name") or "")
if intake_stage == "survey" and name != "analyze_image_reference":
result = {
"ok": False,
"code": "IMAGE_ANALYSIS_REQUIRED",
"message": "Analyze all current image attachments before using planning, library, or generation tools.",
}
model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)})
yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"})
continue
if intake_stage == "sketch" and name != "extract_image_sketch_candidates":
result = {
"ok": False,
"code": "IMAGE_SKETCH_REQUIRED",
"message": "Extract image sketch candidates before using planning or generation tools.",
}
model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)})
yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先提取图片草图候选。"})
continue
if name == "analyze_image_reference" and intake_stage != "survey":
result = {
"ok": False,
"code": "IMAGE_ANALYSIS_ALREADY_RECORDED",
"message": (
"Image analysis is already recorded for the current attachments. "
"Use that result and continue the CAD workflow; do not analyze the image again."
),
}
model_messages.append({
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": json.dumps(result, ensure_ascii=False),
})
yield event("progress", {
"step": name,
"label": self._tool_label(name),
"status": "error",
"message": "图片识别结果已存在,正在继续后续建模流程。",
})
continue
if name == "extract_image_sketch_candidates" and intake_stage != "sketch":
result = {
"ok": False,
"code": "IMAGE_SKETCH_ALREADY_RECORDED",
"message": "Image sketch candidates are already recorded for the current attachments.",
}
model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)})
continue
if name == "search_cdsl_library":
library_searches += 1
if library_searches > 2:
result = {
"ok": False,
"code": "LIBRARY_SEARCH_LIMIT_REACHED",
"message": (
"The CDSL library search limit for this request has been reached. "
"Do not search again. Use the engine guide to call generate_cdsl_model "
"or ask the user one concise clarification question."
),
}
model_messages.append({
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": json.dumps(result, ensure_ascii=False),
})
yield event("progress", {
"step": name,
"label": self._tool_label(name),
"status": "error",
"message": "模型库未找到更多匹配项,正在继续生成模型。",
})
continue
try:
arguments = parse_tool_arguments(
call.get("function", {}).get("arguments"),
recover_cdsl_wrapper=name == "generate_cdsl_model",
)
except ToolArgumentsError as error:
diagnostic_path = self._record_tool_call_diagnostic(
conversation_id=conversation["conversation_id"],
task_id=task_id,
provider=provider,
model=model,
response=response,
finish_reason=response_choice.get("finish_reason"),
iteration=iteration + 1,
call=call,
error=error,
)
if diagnostic_path:
tool_argument_diagnostics.append(diagnostic_path)
result = invalid_tool_arguments_result(name or "tool", error)
if name in GENERATION_TOOL_NAMES:
generate_argument_failures += 1
if generate_argument_failures >= 2:
raise RepeatedToolArgumentsError(str(error), tool_argument_diagnostics)
required_tool_name = name
model_messages.append({
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": json.dumps(result, ensure_ascii=False),
})
yield event("progress", {
"step": name or "tool_arguments",
"label": self._tool_label(name),
"status": "error",
"message": "CAD 工具参数格式无效,正在请求模型修正。",
})
continue
if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR":
current_step_key = get_repair_step_key(planning_state, name)
if repair_step_key != current_step_key:
repair_step_key = current_step_key
repair_attempts = 0
if repair_attempts >= self.settings.max_repair_attempts:
raise CdslRepairLimitError(cdsl_validation_diagnostics)
repair_attempts += 1
cdsl_attempt_path = ""
if name in GENERATION_TOOL_NAMES:
cdsl_attempt_path = self._record_cdsl_attempt(
conversation_id=conversation["conversation_id"],
arguments=arguments,
iteration=iteration + 1,
)
yield event("progress", {
"step": name,
"label": self._tool_label(name),
"status": "running",
"message": "Agent 正在调用本地 CAD 工具。",
})
try:
result, generated = await self._run_tool(
name,
arguments,
task_id,
user_text,
references,
part_skill_selection=part_skill_selection,
planning_state=planning_state,
image_attachment_ids=[str(attachment["id"]) for attachment in image_inputs],
input_attachments=revision_input_attachments(conversation),
conversation_id=conversation["conversation_id"],
repair_attempts=repair_attempts,
)
except (ValueError, RuntimeError) as error:
if name in GENERATION_TOOL_NAMES:
diagnostic_path = self._record_cdsl_validation_diagnostic(
conversation_id=conversation["conversation_id"],
task_id=task_id,
provider=provider,
model=model,
response=response,
finish_reason=response_choice.get("finish_reason"),
iteration=iteration + 1,
call=call,
arguments=arguments,
cdsl_attempt_path=cdsl_attempt_path,
error=error,
)
cdsl_validation_diagnostics.append(diagnostic_path)
result = invalid_cdsl_result(error)
result["diagnostic_path"] = diagnostic_path
repair_task_id = str(getattr(error, "task_id", "") or "")
repair_revision_id = str(getattr(error, "revision_id", "") or "")
if repair_task_id and repair_revision_id:
result.update({
"task_id": repair_task_id,
"base_revision_id": repair_revision_id,
"repair_instruction": (
"Patch this explicit base_revision_id for a local correction, or call read_current_cdsl "
"before a complete CDSL replacement."
),
})
if name == "patch_cdsl_model":
result["code"] = "INVALID_CDSL_PATCH"
result["repair_instruction"] = "Correct the RFC 6902 patch or call generate_cdsl_model with a complete replacement CDSL."
generated = None
required_tool_name = name
planning_state["phase"] = "CDSL_REPAIR"
else:
raise
if result.get("task_id"):
task_id = str(result["task_id"])
if generated:
task_id = generated["task_id"]
model_messages.append({
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": json.dumps(result, ensure_ascii=False),
})
yield event("progress", {
"step": name,
"label": self._tool_label(name),
"status": "success" if result.get("ok", True) else "error",
"message": user_visible_tool_message(result, user_text),
})
if name == "describe_design_intent" and result.get("ok"):
yield event("progress", {
"step": "record_design_brief",
"label": "记录设计说明",
"status": "success",
"message": "设计说明已记录,将作为 CDSL 生成参考。",
})
if name == "analyze_image_reference" and result.get("ok"):
survey = result["observation"]
if result.get("legacy"):
artifact_path = result.get("artifact_path", "")
image_payload = image_observation_payload(survey, stage="complete", artifact_path=artifact_path)
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
yield event("image_analysis", image_payload)
recorded_image_analysis = image_payload
intake_stage = "complete"
required_tool_name = None
tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False)
model_messages.append({"role": "system", "content": image_reference_instruction(image_inputs, survey, "complete")})
yield event("progress", {"step": "analyze_image_reference", "label": "识别图片参考", "status": "success", "message": "已识别图片参考,正在继续 CAD 建模流程。"})
continue
image_payload = image_observation_payload(survey, stage="survey", artifact_path=result.get("artifact_path", ""))
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
yield event("image_analysis", image_payload)
partial_observation = image_payload
intake_stage = "sketch"
required_tool_name = "extract_image_sketch_candidates"
tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=True, image_stage="sketch")
model_messages.append({
"role": "system",
"content": image_reference_instruction(image_inputs, survey, "sketch"),
})
yield event("progress", {
"step": "analyze_image_reference",
"label": "整理图片勘测",
"status": "success",
"message": "已完成多视角图片勘测,正在提取草图候选。",
})
continue
if name == "extract_image_sketch_candidates" and result.get("ok"):
survey = partial_observation or {}
if "observationStage" in survey:
survey = {
"schema_version": survey.get("schemaVersion", "cad.image-observation.v2"),
"attachment_ids": survey.get("attachmentIds") or [],
"part_type": survey.get("partType") or "",
"visible_features": survey.get("visibleFeatures") or [],
"uncertain_features": survey.get("uncertainFeatures") or [],
"views": survey.get("views") or [],
"scale_references": survey.get("scaleReferences") or [],
"overall_geometry": survey.get("overallGeometry") or {},
"surfaces": survey.get("surfaces") or [],
"profiles": survey.get("profiles") or [],
"holes": survey.get("holes") or [],
"bends": survey.get("bends") or [],
"measurements": survey.get("measurements") or [],
"uncertainties": survey.get("uncertainties") or [],
"assumptions": survey.get("assumptions") or [],
"cv_hints": survey.get("cvHints") or [],
}
observation = merge_image_observations(survey, result["sketches"])
artifact_path = self.store.write_conversation_planning(
conversation["conversation_id"],
"image-observation-v2",
observation,
)
image_payload = image_observation_payload(observation, stage="complete", artifact_path=artifact_path)
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
yield event("image_analysis", image_payload)
recorded_image_analysis = image_payload
intake_stage = "complete"
required_tool_name = None
tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False)
model_messages.append({
"role": "system",
"content": image_reference_instruction(image_inputs, observation, "complete"),
})
yield event("progress", {
"step": "extract_image_sketch_candidates",
"label": "提取图片草图",
"status": "success",
"message": "已提取图片轮廓和草图候选,正在继续 CAD 建模流程。",
})
continue
if name in GENERATION_TOOL_NAMES and result.get("ok"):
required_tool_name = None
if result.get("ok", True):
# A successful tool call is forward progress. The next
# generation batch must receive a fresh consecutive-failure budget.
repair_attempts = 0
repair_step_key = None
generate_argument_failures = 0
if generated:
result_payload = {
"taskId": generated["task_id"],
"revisionId": generated["revision_id"],
"cdslPath": generated["cdsl_path"],
"stepPath": generated["step_path"],
"glbPath": generated["glb_path"],
"reportPath": generated["report_path"],
"parametersPath": generated.get("parameters_path"),
"selectorPath": generated.get("selector_path"),
"edgesPath": generated.get("edges_path"),
"summary": generated["summary"],
"referenceIds": generated["reference_ids"],
"engine": generated["engine"],
"qualityStatus": generated.get("quality_status", ""),
"qualityPath": generated.get("quality_path") or None,
"assumptions": generated.get("generation_assumptions", []),
"repairAttempts": generated.get("repair_attempts", 0),
"snapshotPaths": generated.get("snapshot_paths", []),
"snapshotStatus": generated.get("snapshot_status", "unavailable"),
"topologyPath": generated.get("topology_path"),
"planComplete": generated.get("plan_complete", True),
"planStatus": generated.get("plan_status"),
"requiredAction": generated.get("required_action", "complete"),
}
assistant_parts.append({"type": "data-cad-result", "data": result_payload})
yield event("cad_result", result_payload)
yield event("progress", {
"step": "build_cad",
"label": "构建 CAD",
"status": "success",
"message": "已通过 cdsl_only runtime 构建 STEP 和 GLB。",
})
generation_completed = bool(generated.get("plan_complete", True))
if isinstance(generated.get("plan_status"), dict):
last_plan_status = generated["plan_status"]
if generation_completed:
break
if generation_completed:
break
if iteration == MAX_AGENT_TOOL_ITERATIONS - 1:
validation_diagnostics = cdsl_validation_diagnostics
waiting_nodes = [
str(item) for item in (last_plan_status or {}).get("waiting_nodes") or []
]
if waiting_nodes:
message = (
"CAD 基础模型已生成,但特征计划尚未完成。等待拓扑选择的特征:"
+ "、".join(waiting_nodes)
+ "。请先读取当前拓扑,再继续生成这些特征。"
)
elif validation_diagnostics:
diagnostic_paths = "、".join(validation_diagnostics)
if any("\u4e00" <= char <= "\u9fff" for char in user_text):
message = (
"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:"
f"{diagnostic_paths}。"
)
else:
message = (
"Agent tool loop reached its safety limit. "
f"CDSL validation diagnostics were saved to: {diagnostic_paths}."
)
else:
message = "Agent tool loop reached its safety limit."
error_payload = {"stage": "agent", "message": message}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
except Exception as error:
error_payload = {"stage": "agent", "message": user_visible_error_message(error, user_text)}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
if task_id:
self.store.ensure_conversation(conversation["conversation_id"], task_id)
if not assistant_parts:
assistant_parts.append({
"type": "text",
"text": "我暂时没有生成可执行的 CAD 结果。请补充尺寸、形状或修改目标。",
})
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
yield event("progress", {"step": "agent_stream", "label": "调用模型和工具", "status": "success", "message": "Agent 请求已完成。"})
yield event("done", {})
def _persist_assistant(
self,
conversation_id: str,
assistant_id: str,
parts: list[dict[str, Any]],
task_id: str,
) -> None:
self.store.append_conversation_message(
conversation_id,
{
"id": assistant_id or f"assistant_{conversation_id}_{len(parts)}",
"role": "assistant",
"parts": parts,
},
task_id or None,
)
def _record_tool_call_diagnostic(
self,
*,
conversation_id: str,
task_id: str,
provider: ProviderConfig,
model: ProviderModel,
response: dict[str, Any],
finish_reason: Any,
iteration: int,
call: dict[str, Any],
error: ToolArgumentsError,
) -> str:
function = call.get("function") if isinstance(call.get("function"), dict) else {}
raw_arguments = function.get("arguments")
raw_text = raw_arguments if isinstance(raw_arguments, str) else json.dumps(raw_arguments, ensure_ascii=False)
json_error = error.__cause__ if isinstance(error.__cause__, json.JSONDecodeError) else None
payload = {
"schema_version": "1.0",
"recorded_at": now_iso(),
"conversation_id": conversation_id,
"task_id": task_id,
"provider_id": provider.id,
"model_id": model.id,
"strict_tool_schema": model.strict_tool_schema,
"completion_id": response.get("id"),
"response_model": response.get("model"),
"finish_reason": finish_reason,
"usage": response.get("usage"),
"iteration": iteration,
"tool_call_id": call.get("id"),
"tool_name": function.get("name"),
"parse_error": str(error),
"json_error": {
"message": json_error.msg,
"line": json_error.lineno,
"column": json_error.colno,
"character": json_error.pos,
} if json_error else None,
"arguments_type": type(raw_arguments).__name__,
"arguments_utf8_bytes": len(raw_text.encode("utf-8")),
"arguments": raw_arguments,
}
return self.store.write_tool_call_diagnostic(conversation_id, payload)
def _record_cdsl_attempt(
self,
*,
conversation_id: str,
arguments: dict[str, Any],
iteration: int,
) -> str:
candidate = arguments.get("cdsl")
if isinstance(candidate, str):
try:
candidate = json.loads(candidate)
except json.JSONDecodeError:
pass
# Patch calls do not contain a complete CDSL. Preserve their payload
# so an out-of-range path can be diagnosed against the exact request.
if candidate is None and "patches" in arguments:
candidate = {
"kind": "cdsl_patch_attempt",
"base_revision_id": str(arguments.get("base_revision_id") or ""),
"patches": deepcopy(arguments.get("patches") or []),
}
return self.store.write_cdsl_attempt(conversation_id, candidate, iteration)
def _record_cdsl_validation_diagnostic(
self,
*,
conversation_id: str,
task_id: str,
provider: ProviderConfig,
model: ProviderModel,
response: dict[str, Any],
finish_reason: Any,
iteration: int,
call: dict[str, Any],
arguments: dict[str, Any],
cdsl_attempt_path: str,
error: Exception,
) -> str:
function = call.get("function") if isinstance(call.get("function"), dict) else {}
details = _cdsl_error_details(error)
payload = {
"schema_version": "1.0",
"recorded_at": now_iso(),
"kind": "cdsl_validation_failure",
"conversation_id": conversation_id,
"task_id": task_id,
"provider_id": provider.id,
"model_id": model.id,
"strict_tool_schema": model.strict_tool_schema,
"completion_id": response.get("id"),
"response_model": response.get("model"),
"finish_reason": finish_reason,
"usage": response.get("usage"),
"iteration": iteration,
"tool_call_id": call.get("id"),
"tool_name": function.get("name"),
"cdsl_attempt_path": cdsl_attempt_path,
"base_revision_id": arguments.get("base_revision_id"),
"patches": deepcopy(arguments.get("patches")) if "patches" in arguments else None,
"summary": str(arguments.get("summary") or ""),
"assumptions": arguments.get("assumptions") or [],
"verification": arguments.get("verification"),
"diagnostic": details,
"validation_error_type": type(error).__name__,
"validation_error": str(error),
}
return self.store.write_cdsl_validation_diagnostic(conversation_id, payload)
async def _complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
provider: ProviderConfig,
model: ProviderModel,
required_tool_name: str | None = None,
) -> dict[str, Any]:
url = f"{provider.base_url}/chat/completions"
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
tool_choice: str | dict[str, Any] = "auto"
if required_tool_name:
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
payload = {
"model": model.id,
"messages": messages,
"tools": tools,
"tool_choice": tool_choice,
"temperature": 0.1,
}
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
response = await client.post(url, headers=headers, json=payload)
# Some reasoning-enabled, OpenAI-compatible models accept tools but
# reject an explicit tool_choice. Retry once without that constraint.
if (
response.status_code == 400
and "thinking mode does not support this tool_choice" in response.text.lower()
):
payload.pop("tool_choice")
response = await client.post(url, headers=headers, json=payload)
if response.status_code >= 400:
if model.strict_tool_schema:
raise StrictToolSchemaError(
"LLM provider rejected the strict CDSL tool schema "
f"({response.status_code}). Disable CDSL_*_STRICT_TOOL_SCHEMA "
"or CDSL_*_STRICT_TOOL_MODELS for this endpoint, or select a "
"model that supports strict function schemas. "
f"Provider response: {response.text[:500]}"
)
raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}")
return response.json()
async def _run_tool(
self,
name: str,
arguments: dict[str, Any],
task_id: str,
request: str,
references: list[str],
*,
part_skill_selection: dict[str, Any] | None = None,
planning_state: dict[str, Any] | None = None,
image_attachment_ids: list[str] | None = None,
input_attachments: list[dict[str, str | int]] | None = None,
conversation_id: str | None = None,
repair_attempts: int = 0,
) -> tuple[dict[str, Any], dict[str, Any] | None]:
state = planning_state if planning_state is not None else {"phase": "INTAKE", "design_brief": "", "current_model_read": False}
phase = str(state.get("phase") or "INTAKE")
if name == "analyze_image_reference":
if not image_attachment_ids:
raise ValueError("analyze_image_reference requires at least one image attachment")
legacy_arguments = "views" not in arguments and "profiles" not in arguments
if not legacy_arguments:
observation = normalize_image_observation(arguments, attachment_ids=image_attachment_ids)
else:
legacy = normalize_image_analysis(arguments)
observation = normalize_image_observation({
"attachment_ids": image_attachment_ids,
"part_type": legacy["part_type"],
"visible_features": legacy["visible_features"],
"uncertain_features": legacy["uncertain_features"],
"measurements": [
{"name": item["label"], "source": "image", "evidence": item["reason"]}
for item in legacy["dimension_candidates"]
],
"views": [{"attachment_id": attachment_id} for attachment_id in image_attachment_ids],
}, attachment_ids=image_attachment_ids)
analysis = {"ok": True, "legacy": legacy_arguments, "attachment_ids": list(dict.fromkeys(image_attachment_ids)), "observation": observation, **observation}
if conversation_id:
analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "image-survey", analysis["observation"])
state["phase"] = "REFERENCE_ANALYZED"
return analysis, None
if name == "extract_image_sketch_candidates":
if not image_attachment_ids:
raise ValueError("extract_image_sketch_candidates requires at least one image attachment")
sketches = normalize_sketch_candidates(arguments, attachment_ids=image_attachment_ids)
state["phase"] = "REFERENCE_SKETCHED"
return {"ok": True, "sketches": sketches, **sketches}, None
if name == "describe_design_intent":
plan = str(arguments.get("plan") or "").strip()
assumptions = arguments.get("assumptions")
if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions")
state["design_brief"] = plan
state["phase"] = "PLANNED"
return {
"ok": True,
"plan": plan,
"assumptions": [item.strip() for item in assumptions if item.strip()],
"message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.",
}, None
if name == "plan_feature_tree":
if phase not in {"PLANNED", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "PLAN_READY"}:
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before planning the feature tree."}, None
try:
engine = load_engine(self.settings)
plan = validate_feature_plan({
"schema_version": "cad.feature-plan.v1",
"plan_id": arguments.get("plan_id"),
"task_id": task_id,
"nodes": arguments.get("nodes"),
}, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()))
replan = arguments.get("replan")
if replan is not None:
if not isinstance(replan, dict):
raise FeaturePlanError("replan must be an object")
replace_nodes = replan.get("replace_nodes")
reason = str(replan.get("reason") or "").strip()
alternatives = replan.get("alternatives")
if not isinstance(replace_nodes, list) or not replace_nodes or not all(str(item).strip() for item in replace_nodes):
raise FeaturePlanError("replan.replace_nodes must be a non-empty string array")
if not reason or not isinstance(alternatives, list) or not all(isinstance(item, str) for item in alternatives):
raise FeaturePlanError("replan requires reason and alternatives")
prior = state.get("feature_plan") or (self.store.read_feature_plan(task_id) if task_id else None)
if isinstance(prior, dict):
prior_nodes = {str(item.get("id")): item for item in prior.get("nodes") or () if isinstance(item, dict)}
next_nodes = {str(item.get("id")): item for item in plan.get("nodes") or () if isinstance(item, dict)}
replace_set = {str(item) for item in replace_nodes}
if not replace_set.issubset(prior_nodes):
raise FeaturePlanError("replan.replace_nodes must identify existing plan nodes")
completed_replacements = {
node_id for node_id in replace_set
if prior_nodes[node_id].get("status") in {"completed", "executed"}
}
if completed_replacements:
raise FeaturePlanError(
"Replan cannot replace completed nodes: "
+ ", ".join(sorted(completed_replacements))
)
missing_unchanged = set(prior_nodes) - replace_set - set(next_nodes)
if missing_unchanged:
raise FeaturePlanError(
"Replan cannot remove nodes outside replace_nodes: "
+ ", ".join(sorted(missing_unchanged))
)
for node_id in prior_nodes.keys() & next_nodes.keys():
if node_id not in replace_set:
old = {key: value for key, value in prior_nodes[node_id].items() if key not in {"status", "failure"}}
new = {key: value for key, value in next_nodes[node_id].items() if key not in {"status", "failure"}}
if old != new:
raise FeaturePlanError(f"Replan may only change replace_nodes; unchanged node mutated: {node_id}")
plan["replan"] = {
"replace_nodes": [str(item) for item in replace_nodes],
"reason": reason,
"alternatives": [item.strip() for item in alternatives if item.strip()],
}
except (FeaturePlanError, ValueError) as error:
state["phase"] = "BLOCKED"
message = str(error)
code = "FEATURE_PLAN_CYCLE" if "cycle" in message.casefold() else "FEATURE_PLAN_INVALID"
return {"ok": False, "code": code, "message": message}, None
state["feature_plan"] = plan
state["phase"] = "PLAN_READY"
current_cdsl = None
topology = None
if task_id:
cdsl_path = self.store.current_cdsl_path(task_id)
if cdsl_path and cdsl_path.is_file():
current_cdsl = json.loads(cdsl_path.read_text(encoding="utf-8"))
topology_path = self.store.current_topology_path(task_id)
if topology_path and topology_path.is_file():
topology = json.loads(topology_path.read_text(encoding="utf-8"))
self.store.write_feature_plan(task_id, plan)
status = compute_node_statuses(plan, cdsl=current_cdsl, topology=topology)
plan.update({key: status[key] for key in ("nodes", "ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete")})
state["feature_plan"] = plan
if task_id:
self.store.write_feature_plan(task_id, plan)
return {
"ok": True,
"plan_id": plan["plan_id"],
"ready_nodes": plan["ready_nodes"],
"waiting_nodes": plan["waiting_nodes"],
"blocked_nodes": plan["blocked_nodes"],
"completed_nodes": plan["completed_nodes"],
"required_action": "generate_cdsl_model" if plan["ready_nodes"] else "inspect_current_topology" if plan["waiting_nodes"] else "none",
"message": "Feature plan validated. Generate only ready nodes; never invent topology selectors.",
}, None
if name == "search_cdsl_library":
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before searching CDSL references."}, None
query = str(arguments.get("query") or request)
normalized_query = " ".join(query.casefold().split())
seen_queries = state.setdefault("library_queries", [])
if len(seen_queries) >= 2:
return {"ok": False, "code": "LIBRARY_SEARCH_LIMIT_REACHED", "message": "The CDSL library search limit for this request has been reached; continue with the available references."}, None
if normalized_query in seen_queries:
return {"ok": False, "code": "LIBRARY_QUERY_DUPLICATE", "message": "Do not repeat an identical unavailable library query; use the existing results or compile with the available capability."}, None
seen_queries.append(normalized_query)
results = self.library.search(query, min(8, int(arguments.get("limit") or 5)))
state["phase"] = "LIBRARY_SEARCHING"
return {"ok": True, "results": results}, None
if name == "read_cdsl_reference":
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before reading CDSL references."}, None
part_id = str(arguments.get("part_id") or "")
if part_id in references:
return {"ok": False, "code": "LIBRARY_REFERENCE_DUPLICATE", "message": "This CDSL reference is already loaded; choose another reference or continue to compilation."}, None
if len(state.get("reference_records") or []) >= 2:
return {"ok": False, "code": "LIBRARY_REFERENCE_LIMIT_REACHED", "message": "At most two complete CDSL library references may be read for one request."}, None
try:
sample = self.library.read_sample(part_id)
except ValueError as error:
return {"ok": False, "code": "LIBRARY_REFERENCE_NOT_FOUND", "message": str(error)}, None
if part_id not in references:
references.append(part_id)
state.setdefault("reference_records", []).append({"part_id": part_id, "source": "cdsl_library", "summary": "official CDSL reference"})
state["phase"] = "LIBRARY_REFERENCE_READY"
return {"ok": True, "part_id": part_id, "cdsl": sample}, None
if name == "read_current_cdsl":
if not task_id:
return {"ok": False, "message": "No current task exists. This is a new model request."}, None
path = self.store.current_cdsl_path(task_id)
revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
if path is None:
repairable = self.store.latest_repairable_cdsl(task_id)
if repairable is None:
return {"ok": False, "message": "The current task has no successful or repairable CDSL revision."}, None
revision_id, path = repairable
payload: dict[str, Any] = {
"ok": True,
"task_id": task_id,
"revision_id": revision_id,
"cdsl": json.loads(path.read_text(encoding="utf-8")),
}
state["current_model_read"] = True
state["read_revision_id"] = revision_id
state["phase"] = "PLANNED"
return payload, None
if name == "inspect_current_topology":
if phase not in {"PLAN_READY", "TOPOLOGY_READY", "CDSL_AUTHORING", "COMPLETED", "CDSL_REPAIR", "PLANNED"}:
return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "Build a successful CDSL revision before inspecting topology."}, None
result = _topology_query_result(self.store, task_id, arguments)
if result.get("ok") and result.get("records") and isinstance(state.get("feature_plan"), dict):
plan = deepcopy(state["feature_plan"])
plan["topology_snapshot_id"] = str(result.get("snapshot_id") or "")
cdsl_path = self.store.current_cdsl_path(task_id)
topology_path = self.store.current_topology_path(task_id)
cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) if cdsl_path and cdsl_path.is_file() else None
topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
state["feature_plan"] = compute_node_statuses(plan, cdsl=cdsl, topology=topology)
self.store.write_feature_plan(task_id, state["feature_plan"])
return result, None
if name in GENERATION_TOOL_NAMES:
if phase not in {"PLANNED", "PLAN_READY", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "COMPLETED"}:
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None
selection = part_skill_selection or self.part_skill_library.select(request)
if selection.get("conflict"):
state["phase"] = "BLOCKED"
return {
"ok": False,
"code": "PART_SKILL_CONFLICT",
"message": str(selection["conflict"].get("message") or "Resolve the primary part-family conflict before generating CAD."),
}, None
state["phase"] = "CDSL_AUTHORING"
if name == "generate_cdsl_model":
if task_id and not state.get("current_model_read"):
return {"ok": False, "code": "CURRENT_CDSL_REQUIRED", "message": "Call read_current_cdsl before replacing an existing task revision."}, None
cdsl = arguments.get("cdsl")
if isinstance(cdsl, str):
cdsl = json.loads(cdsl)
if not isinstance(cdsl, dict):
raise ValueError("generate_cdsl_model requires a CDSL JSON object")
parent_revision_id = str(state.get("read_revision_id") or "") if task_id else ""
if task_id and not parent_revision_id:
parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"}
if phase == "CDSL_REPAIR":
operation["type"] = "cdsl_repair"
else:
if not task_id:
return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None
base_revision_id = str(arguments.get("base_revision_id") or "")
current_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
if not current_revision_id or base_revision_id != current_revision_id:
raise ValueError("TOPOLOGY_SNAPSHOT_STALE: base_revision_id must be the current successful revision")
base_path = self.store.revision_cdsl_path(task_id, base_revision_id)
if base_path is None:
raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision")
try:
base_cdsl = json.loads(base_path.read_text(encoding="utf-8"))
cdsl = apply_cdsl_patch(base_cdsl, arguments.get("patches"))
except (CdslPatchError, json.JSONDecodeError) as error:
raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error
parent_revision_id = base_revision_id
operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])}
base_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(base_cdsl)}
next_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(cdsl)}
if any(source in {"runtime_snapshot", "viewer_selection"} for source, _stable_id, _snapshot_id in next_selectors - base_selectors):
operation["type"] = "cdsl_selector_patch"
if state.get("feature_plan"):
operation["type"] = "cdsl_plan_batch" if operation.get("type") in {"cdsl_create", "cdsl_replacement"} else operation.get("type")
operation["plan_id"] = str(state["feature_plan"].get("plan_id") or "")
operation["plan_nodes"] = [
str(node.get("id")) for node in state["feature_plan"].get("nodes") or ()
if isinstance(node, dict) and node.get("status") in {"ready", "executing"}
]
cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl)
_validate_plan_cdsl_transition(self.store, task_id, state.get("feature_plan"), cdsl)
_validate_snapshot_selectors(self.store, task_id, cdsl)
# Reject malformed model output before build_revision allocates a task
# directory or revision. build_revision will assign the real task ID.
preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")}
engine = load_engine(self.settings)
state["phase"] = "PREFLIGHT"
validate_cdsl(preflight_cdsl, engine)
summary = str(arguments.get("summary") or "Parameterized CAD model")
raw_assumptions = arguments.get("assumptions") or []
if not isinstance(raw_assumptions, list) or not all(isinstance(item, str) for item in raw_assumptions):
raise ValueError(f"{name} assumptions must be an array of strings")
assumptions = [item.strip() for item in raw_assumptions if item.strip()]
verification = arguments.get("verification")
validate_verification(verification, cdsl)
part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions)
state["phase"] = "BUILDING"
try:
yieldable = await asyncio.to_thread(
build_revision,
settings=self.settings,
store=self.store,
task_id=task_id or None,
request=request,
cdsl=cdsl,
reference_ids=list(references),
summary=summary,
parent_revision_id=parent_revision_id,
operation=operation,
part_skills=part_skill_audit,
generation_assumptions=assumptions,
repair_attempts=repair_attempts,
input_attachments=input_attachments,
verification=verification,
reference_records=state.get("reference_records"),
feature_plan=state.get("feature_plan"),
)
except Exception:
state["phase"] = "CDSL_REPAIR"
raise
plan_status: dict[str, Any] | None = None
if state.get("feature_plan"):
topology_path = self.store.current_topology_path(yieldable["task_id"])
topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
# A successful build already produced the authoritative topology
# snapshot. Bind it here so advancing a plan never depends on the
# model remembering a bookkeeping-only topology inspection call.
plan = deepcopy(state["feature_plan"])
snapshot_id = str((topology or {}).get("snapshot_id") or "")
if snapshot_id:
plan["topology_snapshot_id"] = snapshot_id
plan_status = compute_node_statuses(plan, cdsl=cdsl, topology=topology)
state["feature_plan"] = plan_status
self.store.write_feature_plan(yieldable["task_id"], plan_status)
state["phase"] = "COMPLETED" if plan_status["complete"] else "TOPOLOGY_READY"
else:
state["phase"] = "COMPLETED"
yieldable["plan_complete"] = bool(plan_status["complete"]) if plan_status else True
yieldable["plan_status"] = plan_status
return {
"ok": True,
"summary": summary,
"task_id": yieldable["task_id"],
"revision_id": yieldable["revision_id"],
"normalization_repairs": normalization_repairs,
"operation": operation,
"plan_complete": bool(plan_status["complete"]) if plan_status else True,
"plan_status": {
key: plan_status[key]
for key in ("ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete")
} if plan_status else None,
"required_action": (
"inspect_current_topology" if plan_status and plan_status["waiting_nodes"]
else "patch_cdsl_model" if plan_status and plan_status["ready_nodes"]
else "complete"
) if plan_status else "complete",
}, yieldable
raise ValueError(f"Unknown agent tool: {name}")
@staticmethod
def _chunks(text: str) -> list[str]:
return [text[index:index + 96] for index in range(0, len(text), 96)]
@staticmethod
def _tool_label(name: str) -> str:
return {
"analyze_image_reference": "整理图片勘测",
"extract_image_sketch_candidates": "提取图片草图",
"search_cdsl_library": "检索 CDSL 模型库",
"read_cdsl_reference": "读取 CDSL 参考模型",
"read_current_cdsl": "读取当前 CDSL",
"describe_design_intent": "整理设计说明",
"plan_feature_tree": "规划特征树",
"inspect_current_topology": "查询当前拓扑",
"generate_cdsl_model": "生成 CDSL",
"patch_cdsl_model": "修复 CDSL",
}.get(name, "调用 CAD 工具")
def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str:
attachments = conversation.get("attachments") or []
if not attachments:
return ""
conversation_id = str(conversation.get("conversation_id") or "")
if not conversation_id:
raise ValueError("Conversation attachment has no conversation id")
content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request. Image ids are stable references for the visual survey."}]
for attachment in attachments:
if not isinstance(attachment, dict):
continue
kind = str(attachment.get("kind") or "")
attachment_conversation = str(attachment.get("conversation_id") or "")
relative = str(attachment.get("path") or "")
if attachment_conversation != conversation_id or not relative:
raise ValueError("Conversation attachment metadata is invalid")
path = self.store.conversation_attachment_path(conversation_id, relative)
if not path.is_file():
raise ValueError(f"Conversation attachment is missing: {attachment.get('name') or attachment.get('id')}")
if kind == "image":
if not model.vision:
raise ValueError("The selected model does not support images. Choose a vision-capable model enabled in backend/.env.")
mime = str(attachment.get("mime") or "image/png")
metadata = {
"width": attachment.get("width"),
"height": attachment.get("height"),
"orientation": attachment.get("orientation"),
}
try:
hints = cv_hints(path.read_bytes())
except OSError:
hints = {"available": False, "hints": []}
content.append({
"type": "text",
"text": (
f"IMAGE_ID: {attachment.get('id')}\n"
f"FILE_NAME: {attachment.get('name')}\n"
f"MIME: {mime}\n"
f"METADATA: {json.dumps(metadata, ensure_ascii=False, separators=(',', ':'))}\n"
f"CV_HINTS: {json.dumps(hints, ensure_ascii=False, separators=(',', ':'))}"
),
})
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}})
elif kind == "document":
extracted = str(attachment.get("extracted_path") or "")
if extracted:
text_path = self.store.conversation_attachment_path(conversation_id, extracted)
text = text_path.read_text(encoding="utf-8")[:30_000]
content.append({"type": "text", "text": f"Document {attachment.get('name')}:\n{text}"})
return content