change
This commit is contained in:
@@ -2,21 +2,191 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
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
|
||||
from app.services.engine_service import build_revision, load_engine, validate_cdsl
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.sse import event
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.services.storage import WorkspaceStore, now_iso
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
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"}):
|
||||
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 parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = False) -> dict[str, Any]:
|
||||
"""Decode one function-call argument object, with one guarded CDSL repair."""
|
||||
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
|
||||
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}. "
|
||||
"Call the same tool again with exactly one valid JSON object. "
|
||||
"Do not append prose, Markdown fences, or another JSON value."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def invalid_cdsl_result(error: ValueError) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "INVALID_CDSL",
|
||||
"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 == "INVALID_CDSL":
|
||||
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")
|
||||
return json.loads((engine_dir / schema_name).read_text(encoding="utf-8"))
|
||||
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()
|
||||
|
||||
|
||||
TOOL_SCHEMAS: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "function",
|
||||
@@ -54,17 +224,34 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cdsl": {"type": "object", "description": "Complete cad.cdsl.llm.v1 JSON object."},
|
||||
"summary": {"type": "string"},
|
||||
"cdsl": CDSL_TOOL_SCHEMA,
|
||||
"summary": {"type": "string", "minLength": 1},
|
||||
"assumptions": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["cdsl", "summary"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def tools_for_model(model: ProviderModel) -> list[dict[str, Any]]:
|
||||
"""Return this model's tool contract without mutating the shared schema."""
|
||||
tools = deepcopy(TOOL_SCHEMAS)
|
||||
if not model.strict_tool_schema:
|
||||
return tools
|
||||
|
||||
generate_tool = next(
|
||||
tool for tool in tools
|
||||
if tool.get("function", {}).get("name") == "generate_cdsl_model"
|
||||
)
|
||||
# This flag constrains function arguments only. It has no effect on normal
|
||||
# assistant text, the user's prompt, or the natural-language summary.
|
||||
generate_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()
|
||||
|
||||
@@ -78,14 +265,139 @@ def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
def system_prompt(settings: Settings) -> str:
|
||||
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")),
|
||||
"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 system_prompt(
|
||||
settings: Settings,
|
||||
user_text: str,
|
||||
viewer_context: list[dict[str, Any]] | None = None,
|
||||
task_id: str = "",
|
||||
) -> str:
|
||||
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 ""
|
||||
readme_path = settings.engine_root / "README.md"
|
||||
engine_readme = readme_path.read_text(encoding="utf-8") if readme_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 ""
|
||||
supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS))
|
||||
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.
|
||||
- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly,
|
||||
not as Markdown and not as a concatenated JSON string.
|
||||
- The generate_cdsl_model `cdsl` parameter is the complete machine-enforced
|
||||
schema. Satisfy its nested object and array types exactly; do not substitute
|
||||
a shorthand array for an object. For example, every hole position is
|
||||
`{{"mm": [u_mm, v_mm, w_mm]}}`, never `[u_mm, v_mm]`.
|
||||
- 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 generate_cdsl_model reports INVALID_CDSL, correct the full CDSL object and
|
||||
call it again. Do not submit a partial object or claim success.
|
||||
|
||||
Workflow limits:
|
||||
- Do not expose internal planning or "let me" commentary to the user while
|
||||
using tools. The application shows tool progress separately.
|
||||
- Use at most two CDSL-library searches per user request. If neither finds a
|
||||
useful reference, stop searching and use the engine guide 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)}
|
||||
|
||||
You generate parameterized CDSL, never raw CAD source code. For new CAD requests:
|
||||
1. Search the local official CDSL library.
|
||||
2. Read at least one relevant reference when a match exists.
|
||||
@@ -95,7 +407,9 @@ You generate parameterized CDSL, never raw CAD source code. For new CAD requests
|
||||
For a revision, call read_current_cdsl first and preserve unrelated features.
|
||||
Do not output compiler_context, unknown_shape, complex_arc_shape, entities,
|
||||
contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles
|
||||
supported by the engine. Ask a concise clarification question when essential
|
||||
and feature atomic IDs defined in the engine schema below. Read the engine
|
||||
schema before selecting an atomic ID, a profile, or their parameter names. Do
|
||||
not invent an atomic ID, profile, or their fields. Ask a concise clarification question when essential
|
||||
dimensions or intent are missing. Ordinary explanations must not create CAD.
|
||||
|
||||
Local skill:
|
||||
@@ -104,8 +418,12 @@ Local skill:
|
||||
Local engine guide:
|
||||
{engine_readme}
|
||||
|
||||
Authoritative engine schema:
|
||||
{profile_schema}
|
||||
|
||||
Supported named profile types:
|
||||
{supported_profiles}
|
||||
{viewer_selection_prompt(viewer_context, task_id)}
|
||||
"""
|
||||
|
||||
|
||||
@@ -122,6 +440,7 @@ class AgentService:
|
||||
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:
|
||||
@@ -169,35 +488,117 @@ class AgentService:
|
||||
|
||||
yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"})
|
||||
references: list[str] = []
|
||||
model_messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt(self.settings)}]
|
||||
library_searches = 0
|
||||
model_messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt(self.settings, user_text, viewer_context, task_id)}]
|
||||
model_messages.extend(messages_for_model(messages))
|
||||
if attachment_message:
|
||||
model_messages.append({"role": "user", "content": attachment_message})
|
||||
tools = TOOL_SCHEMAS
|
||||
tools = tools_for_model(model)
|
||||
required_tool_name: str | None = None
|
||||
generate_argument_failures = 0
|
||||
tool_argument_diagnostics: list[str] = []
|
||||
|
||||
try:
|
||||
for iteration in range(8):
|
||||
response = await self._complete(model_messages, tools, provider, model)
|
||||
choice = response["choices"][0]["message"]
|
||||
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 "")
|
||||
if content:
|
||||
# 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 "")
|
||||
arguments = json.loads(call.get("function", {}).get("arguments") or "{}")
|
||||
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 == "generate_cdsl_model":
|
||||
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
|
||||
yield event("progress", {
|
||||
"step": name,
|
||||
"label": self._tool_label(name),
|
||||
"status": "running",
|
||||
"message": "Agent 正在调用本地 CAD 工具。",
|
||||
})
|
||||
result, generated = await self._run_tool(name, arguments, task_id, user_text, references)
|
||||
try:
|
||||
result, generated = await self._run_tool(name, arguments, task_id, user_text, references)
|
||||
except (ValueError, RuntimeError) as error:
|
||||
if name == "generate_cdsl_model":
|
||||
result = invalid_cdsl_result(error)
|
||||
generated = None
|
||||
required_tool_name = name
|
||||
else:
|
||||
raise
|
||||
if generated:
|
||||
task_id = generated["task_id"]
|
||||
model_messages.append({
|
||||
@@ -209,8 +610,10 @@ class AgentService:
|
||||
"step": name,
|
||||
"label": self._tool_label(name),
|
||||
"status": "success" if result.get("ok", True) else "error",
|
||||
"message": result.get("message") or result.get("summary") or "",
|
||||
"message": user_visible_tool_message(result, user_text),
|
||||
})
|
||||
if name == "generate_cdsl_model" and result.get("ok"):
|
||||
required_tool_name = None
|
||||
if generated:
|
||||
result_payload = {
|
||||
"taskId": generated["task_id"],
|
||||
@@ -234,7 +637,7 @@ class AgentService:
|
||||
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": str(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:
|
||||
@@ -267,19 +670,82 @@ class AgentService:
|
||||
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)
|
||||
|
||||
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"}
|
||||
payload = {"model": model.id, "messages": messages, "tools": tools, "tool_choice": "auto", "temperature": 0.1}
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -306,6 +772,10 @@ class AgentService:
|
||||
cdsl = json.loads(cdsl)
|
||||
if not isinstance(cdsl, dict):
|
||||
raise ValueError("generate_cdsl_model requires a CDSL JSON object")
|
||||
# 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")}
|
||||
validate_cdsl(preflight_cdsl, load_engine(self.settings))
|
||||
summary = str(arguments.get("summary") or "CDSL CAD model")
|
||||
yieldable = await asyncio.to_thread(
|
||||
build_revision,
|
||||
|
||||
@@ -8,6 +8,9 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
|
||||
from vendor.cdsl_preview_runtime import step_to_glb
|
||||
from app.services.storage import WorkspaceStore, now_iso, write_json
|
||||
from app.settings import Settings
|
||||
@@ -34,11 +37,50 @@ def _walk(value: Any) -> list[tuple[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
def _engine_schema(engine: Any) -> dict[str, Any]:
|
||||
schema_path = Path(str(engine.__file__)).with_name("profile_schema.json")
|
||||
try:
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
|
||||
if not isinstance(schema, dict) or not isinstance(schema.get("feature_atomic_ids"), dict):
|
||||
raise RuntimeError("The local engine schema has no feature_atomic_ids contract")
|
||||
return schema
|
||||
|
||||
|
||||
def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
|
||||
document = _engine_schema(engine)
|
||||
schema_name = str(document.get("cdsl_json_schema_file") or "")
|
||||
if not schema_name or Path(schema_name).name != schema_name:
|
||||
raise RuntimeError("The local engine schema has an invalid CDSL JSON Schema path")
|
||||
schema_path = Path(str(engine.__file__)).with_name(schema_name)
|
||||
try:
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except (OSError, json.JSONDecodeError, SchemaError) as error:
|
||||
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
|
||||
return schema
|
||||
|
||||
|
||||
def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
validator = Draft202012Validator(load_cdsl_json_schema(engine))
|
||||
errors = sorted(validator.iter_errors(cdsl), key=lambda error: (list(error.absolute_path), error.message))
|
||||
if not errors:
|
||||
return
|
||||
error = errors[0]
|
||||
location = "$" + "".join(
|
||||
f"[{item}]" if isinstance(item, int) else f".{item}"
|
||||
for item in error.absolute_path
|
||||
)
|
||||
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
if not isinstance(cdsl, dict):
|
||||
raise ValueError("CDSL must be a JSON object")
|
||||
if cdsl.get("schema") != "cad.cdsl.llm.v1":
|
||||
raise ValueError("Unsupported CDSL schema")
|
||||
_validate_cdsl_json_schema(cdsl, engine)
|
||||
part_id = str(cdsl.get("part_id") or "")
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_-]{3,80}", part_id):
|
||||
raise ValueError("part_id must use letters, numbers, underscores, or hyphens")
|
||||
@@ -51,16 +93,42 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
if not isinstance(features, list) or not features or not isinstance(sketches, list) or not sketches:
|
||||
raise ValueError("CDSL requires features and parameterized sketches")
|
||||
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
|
||||
semantic_contract = _engine_schema(engine)
|
||||
atomic_contracts = semantic_contract["feature_atomic_ids"]
|
||||
supported_atomic_ids = sorted(
|
||||
str(atomic_id)
|
||||
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
|
||||
)
|
||||
feature_ids: set[str] = set()
|
||||
for feature in features:
|
||||
fid = str(feature.get("id") or "")
|
||||
if not fid or fid in feature_ids:
|
||||
raise ValueError("Feature ids must be unique")
|
||||
feature_ids.add(fid)
|
||||
if feature.get("execution_status") not in (None, "supported"):
|
||||
raise ValueError(f"Feature {fid} is deferred and cannot be rebuilt by the current engine")
|
||||
if str(feature.get("sketch_id") or "") not in sketch_ids:
|
||||
raise ValueError(f"Feature {fid} refers to a missing sketch")
|
||||
if not str(feature.get("atomic_id") or ""):
|
||||
atomic_id = str(feature.get("atomic_id") or "")
|
||||
if not atomic_id:
|
||||
raise ValueError(f"Feature {fid} has no atomic_id")
|
||||
contract = atomic_contracts.get(atomic_id)
|
||||
if atomic_id not in supported_atomic_ids or not isinstance(contract, dict):
|
||||
raise ValueError(
|
||||
f"Unsupported CDSL atomic_id: {atomic_id}. Supported: {', '.join(supported_atomic_ids)}"
|
||||
)
|
||||
params = feature.get("params")
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError(f"Feature {fid} params must be an object")
|
||||
for parameter_name in contract.get("required_params") or []:
|
||||
if params.get(parameter_name) is None:
|
||||
raise ValueError(f"Feature {fid} ({atomic_id}) is missing required parameter: {parameter_name}")
|
||||
if atomic_id.startswith("extrude_") and float(params.get("distance_mm") or 0) <= 0:
|
||||
raise ValueError(f"Feature {fid} ({atomic_id}) requires distance_mm > 0 for runtime rebuild")
|
||||
if atomic_id.startswith("revolve_") and float(params.get("angle_deg") or 0) <= 0:
|
||||
raise ValueError(f"Feature {fid} ({atomic_id}) requires angle_deg > 0 for runtime rebuild")
|
||||
if contract.get("requires_sketch") and str(feature.get("sketch_id") or "") not in sketch_ids:
|
||||
raise ValueError(f"Feature {fid} ({atomic_id}) requires a valid sketch_id")
|
||||
for dependency in feature.get("depends_on") or []:
|
||||
if dependency not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} has a forward or missing dependency")
|
||||
@@ -76,6 +144,10 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError("Polygon profiles require vertices")
|
||||
elif profile_type not in engine.SHAPE_GENERATORS:
|
||||
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
|
||||
try:
|
||||
engine.compile_cdsl(copy.deepcopy(cdsl))
|
||||
except Exception as error:
|
||||
raise ValueError(f"CDSL engine compile preflight failed: {error}") from error
|
||||
|
||||
|
||||
def _parameter_id(path: list[str]) -> str:
|
||||
|
||||
@@ -72,6 +72,14 @@ class WorkspaceStore:
|
||||
def conversation_path(self, conversation_id: str) -> Path:
|
||||
return self.conversation_dir(conversation_id) / "conversation.json"
|
||||
|
||||
def write_tool_call_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
|
||||
"""Persist one failed model tool call without creating a CAD revision."""
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
relative = Path("diagnostics") / f"tool_call_{secrets.token_hex(8)}.json"
|
||||
path = self.conversation_dir(conversation) / relative
|
||||
write_json(path, payload)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def ensure_conversation(
|
||||
self,
|
||||
conversation_id: str | None,
|
||||
|
||||
Reference in New Issue
Block a user