"""HTTP-facing lifecycle owner for the autonomous CDSL author.""" from __future__ import annotations import asyncio import base64 from collections.abc import AsyncIterator, Awaitable, Callable import json import secrets from pathlib import Path from typing import Any import httpx from app.models.contracts import ChatMessage from app.services.autonomous_cdsl_generation import AutonomousCdslGenerationRunner from app.services.library import CdslLibrary from app.services.review_renderer import renderer_status from app.services.sse import event from app.services.storage import WorkspaceStore, now_iso from app.settings import ProviderConfig, ProviderModel, Settings def text_from_message(message: ChatMessage) -> str: return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip() def _response_language(text: str) -> str: cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff") latin = sum(1 for char in text if char.isascii() and char.isalpha()) return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English" _EVENT_LABELS = { "requirements_document": "冻结需求", "completion_checklist": "完成清单", "completion_audit": "完成审计", "modeling_plan": "建模计划", "modeling_plan_review": "计划独立复核", "tool_call": "建模工具", "candidate_result": "候选构建", "candidate_review": "候选独立复核", "geometry_diagnostic": "几何诊断", "geometry_conclusion": "几何结论", "step_review": "步骤审查", "checkpoint": "构建检查点", "rollback": "回滚检查点", "final_review": "最终视觉复核", "task_terminal": "生成任务", } def _event_status(name: str, payload: dict[str, Any]) -> str: if name == "task_terminal": lifecycle = str(payload.get("lifecycle") or "") if lifecycle == "failed": return "error" if lifecycle == "cancelled": return "cancelled" return "success" review = payload.get("review") if isinstance(payload.get("review"), dict) else {} if (name == "modeling_plan_review" and str(review.get("verdict") or "") == "revise") or ( name == "candidate_review" and str(review.get("verdict") or "") == "reject" ) or ( name == "final_review" and str(review.get("verdict") or "") == "repair" ): return "error" return str(payload.get("status") or "running") def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]: data = dict(payload) data.update({"step": name, "label": _EVENT_LABELS.get(name, name), "status": _event_status(name, payload)}) return data def _append_text_part(parts: list[dict[str, Any]], text: str) -> None: if not text: return if parts and parts[-1].get("type") == "text": parts[-1]["text"] = str(parts[-1].get("text") or "") + text else: parts.append({"type": "text", "text": text}) def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None: part_id = str(part.get("id") or "") if part_id: for index, existing in enumerate(parts): if str(existing.get("id") or "") == part_id: parts[index] = part return parts.append(part) class _StreamingUnsupported(RuntimeError): pass def conversation_user_context(conversation: dict[str, Any]) -> list[dict[str, str]]: """Return durable user intent from the whole conversation. A terminal CAD task always starts a fresh frozen requirements document, but it must not lose the earlier user messages that explain what a short follow-up means. This deliberately has no keyword handling: ``retry``, ``change it`` and a full replacement prompt are all ordinary conversation turns. Assistant prose is excluded because it is generated evidence, not user intent. """ entries: list[str] = [] for message in conversation.get("messages") or (): if not isinstance(message, dict) or message.get("role") != "user": continue parts = message.get("parts") or () text = "\n".join( str(part.get("text") or "") for part in parts if isinstance(part, dict) and str(part.get("type") or "") == "text" ).strip() if text: entries.append(text) if not entries: return [] # Bound history without giving an arbitrary short follow-up precedence # over its preceding detailed request. The latest user turn remains last. selected: list[str] = [] remaining = 18_000 for text in reversed(entries): if len(text) > remaining and selected: break selected.append(text[-remaining:]) remaining -= len(selected[-1]) if remaining <= 0 or len(selected) >= 12: break selected.reverse() return [{ "role": "user", "content": "Conversation user context, ordered from earlier to latest. Use it to interpret the current CAD request; do not treat short follow-ups as a replacement unless they explicitly say so:\n\n" + "\n\n--- Next user turn ---\n\n".join(selected), }] class AgentService: def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None: self.settings = settings self.store = store self.library = library self._autonomous_runs: dict[str, asyncio.Task[None]] = {} async def resume_running_tasks(self) -> None: """Restart only durable native autonomous workers after process recovery.""" for task in self.store.running_tasks(): task_id = str(task.get("task_id") or "") state = self.store.read_agent_state(task_id) if task_id else None if not task_id or task_id in self._autonomous_runs: continue if str(task.get("schema_version") or "") != "2.0" or not isinstance(state, dict): self.store.finish_generation(task_id, lifecycle="failed", failure={ "schema_version": "cad.autonomous-failure.v1", "stage": "recovery", "error_code": "AUTONOMOUS_PROTOCOL_OBSOLETE", "message": "This running task uses the retired planning/strict-authoring protocol and cannot be resumed.", }) continue try: provider, model = self.settings.resolve_model(str(state.get("provider_id") or ""), str(state.get("model_id") or "")) except ValueError as error: self.store.finish_generation(task_id, lifecycle="failed", failure={ "schema_version": "cad.autonomous-failure.v1", "stage": "recovery", "message": str(error), }) continue try: self.settings.resolve_independent_review_model(provider, model) renderer_ready, renderer_detail = renderer_status() if not renderer_ready: raise ValueError(renderer_detail) except ValueError as error: self.store.finish_generation(task_id, lifecycle="failed", failure={ "schema_version": "cad.autonomous-failure.v1", "stage": "recovery", "error_code": "FINAL_REVIEW_UNAVAILABLE", "message": str(error), }) continue request = str(state.get("request") or task.get("request") or "") conversation_id = str(state.get("conversation_id") or "") initial_messages = state.get("initial_messages") if isinstance(state.get("initial_messages"), list) else [] runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete) async def consume( *, task_id: str = task_id, request: str = request, conversation_id: str = conversation_id, provider: ProviderConfig = provider, model: ProviderModel = model, initial_messages: list[dict[str, Any]] = initial_messages, runner: AutonomousCdslGenerationRunner = runner, ) -> None: try: async for _name, _payload in runner.run( task_id=task_id, request=request, conversation_id=conversation_id, provider=provider, model=model, initial_messages=initial_messages, already_started=True, ): pass finally: self._autonomous_runs.pop(task_id, None) self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"resume-autonomous-cdsl-{task_id}") async def cancel(self, task_id: str) -> dict[str, Any] | None: task = self.store.read_task(task_id) if task is None: return None running = self._autonomous_runs.pop(task_id, None) if running and not running.done(): running.cancel() if str(task.get("lifecycle") or "") == "running": task = self.store.finish_generation(task_id, lifecycle="cancelled", failure={ "schema_version": "cad.autonomous-cancelled.v1", "stage": "cancelled", "message": "CAD generation was cancelled by the user.", }) return task async def stream( self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None, ) -> AsyncIterator[bytes]: del viewer_context # Viewer topology remains frozen while a run executes. 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) if not user_text: yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."}) yield event("done", {}) return 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 if task_id and current_task is None: yield event("cad_error", {"stage": "request", "message": "The selected CAD task no longer exists."}) yield event("done", {}) return if current_task and str(current_task.get("lifecycle") or "") == "running": yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"}) yield event("done", {}) return try: provider, model = self.settings.resolve_model(provider_id, model_id) except ValueError as error: yield event("cad_error", {"stage": "configuration", "message": str(error)}) yield event("done", {}) return # This is a fail-closed delivery gate, not a per-checkpoint cost. A # model must never spend a long authoring run only to discover at final # publication that independent visual review cannot run. try: self.settings.resolve_independent_review_model(provider, model) renderer_ready, renderer_detail = renderer_status() if not renderer_ready: raise ValueError(renderer_detail) except ValueError as error: yield event("cad_error", {"stage": "configuration", "message": f"最终视觉复核不可用:{error}"}) yield event("done", {}) return try: attachment_content = self._attachment_message(conversation, model) except ValueError as error: yield event("cad_error", {"stage": "attachment", "message": str(error)}) yield event("done", {}) return if not task_id: created = self.store.ensure_task(None, user_text) task_id = str(created["task_id"]) current_task = created elif str(current_task.get("schema_version") or "") != "2.0": yield event("cad_error", {"stage": "generation", "message": "AUTONOMOUS_PROTOCOL_OBSOLETE: this task belongs to the retired protocol. Start a new CAD task."}) yield event("done", {}) return else: # requirements.md is immutable for a task. A post-terminal user # request therefore starts a clean autonomous run instead of # silently mixing two independent frozen specifications. created = self.store.ensure_task(None, user_text) task_id = str(created["task_id"]) current_task = created conversation = self.store.append_conversation_message( conversation["conversation_id"], latest_user.model_dump(), task_id, ) initial_messages: list[dict[str, Any]] = conversation_user_context(conversation) if attachment_content: initial_messages.append({"role": "user", "content": attachment_content}) frozen_attachment_ids = [ str(attachment.get("id") or "") for attachment in conversation.get("attachments") or () if isinstance(attachment, dict) and str(attachment.get("id") or "") ] self.store.start_generation(task_id, request=user_text) yield event("progress", { "taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "CAD 任务已启动。" if _response_language(user_text) == "Chinese" else "CAD task started.", }) queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() assistant_parts: list[dict[str, Any]] = [] sequence = 0 async def on_text_delta(text: str) -> None: # Text is persisted and streamed through the same ordered queue as # tool events, so a refresh produces the exact same transcript. nonlocal sequence sequence += 1 _append_text_part(assistant_parts, text) await queue.put(("text_delta", { "text": text, "taskId": task_id, "eventId": f"{task_id}_{sequence}_text_delta", "sequence": sequence, "timestamp": now_iso(), })) runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete, on_text_delta=on_text_delta) assistant_id = f"assistant_{secrets.token_hex(8)}" async def consume() -> None: nonlocal sequence terminal = "" try: async for name, payload in runner.run( task_id=task_id, request=user_text, conversation_id=conversation["conversation_id"], provider=provider, model=model, initial_messages=initial_messages, frozen_attachment_ids=frozen_attachment_ids, already_started=True, ): sequence += 1 decorated = dict(payload) decorated.setdefault("taskId", task_id) decorated.setdefault("eventId", f"{task_id}_{sequence}_{name}") decorated.update({ "sequence": sequence, "timestamp": now_iso(), }) if name == "cad_result": _upsert_data_part(assistant_parts, {"type": "data-cad-result", "id": decorated["eventId"], "data": decorated}) elif name == "agent_thinking": visible = str(payload.get("message") or "") if visible: # Streaming completions already delivered this # content through on_text_delta; only persist the # full response for the compatibility path. if not assistant_parts or assistant_parts[-1].get("type") != "text" or visible not in str(assistant_parts[-1].get("text") or ""): _append_text_part(assistant_parts, visible + "\n\n") elif name == "task_terminal": terminal = str(payload.get("lifecycle") or "") if terminal == "failed": _upsert_data_part(assistant_parts, {"type": "data-cad-error", "id": decorated["eventId"], "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}}) elif name != "text_delta": _upsert_data_part(assistant_parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)}) await queue.put((name, decorated)) except Exception as error: self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)}) terminal = "failed" payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)} error_id = f"{task_id}_{sequence + 1}_cad_error" assistant_parts.append({"type": "data-cad-error", "id": error_id, "data": {"stage": "generation", "message": str(error)}}) await queue.put(("task_terminal", payload)) finally: if terminal == "completed" and not assistant_parts: assistant_parts.append({"type": "text", "text": "CAD 模型已完成。"}) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id) self._autonomous_runs.pop(task_id, None) await queue.put(None) self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cdsl-{task_id}") while True: try: item = await asyncio.wait_for(queue.get(), timeout=15) except asyncio.TimeoutError: yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()}) continue if item is None: break name, payload = item if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed": yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}) elif name == "agent_thinking": message = str(payload.get("message") or "") if message: yield event("text_delta", {"text": message + "\n\n"}) else: yield event(name, payload) 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, "role": "assistant", "parts": parts}, task_id or None, ) async def _complete( self, messages: list[dict[str, Any]], tools: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, required_tool_name: str | None = None, *, on_text_delta: Callable[[str], Awaitable[None]] | None = None, ) -> dict[str, Any]: if on_text_delta is None: return await self._complete_once(messages, tools, provider, model, required_tool_name) try: return await self._complete_stream(messages, tools, provider, model, required_tool_name, on_text_delta) except _StreamingUnsupported: await on_text_delta("当前模型不支持工具流式,已切换为兼容模式。\n\n") response = await self._complete_once(messages, tools, provider, model, required_tool_name) content = str((((response.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "") if content: await on_text_delta(content + "\n\n") # Prevent the runner from emitting this same full response again. choice = ((response.get("choices") or [{}])[0] or {}).get("message") or {} return {"choices": [{"message": {**choice, "content": ""}}]} async def _complete_stream( self, messages: list[dict[str, Any]], tools: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, required_tool_name: str | None, on_text_delta: Callable[[str], Awaitable[None]], ) -> dict[str, Any]: tool_choice: str | dict[str, Any] = "auto" if required_tool_name: tool_choice = {"type": "function", "function": {"name": required_tool_name}} payload: dict[str, Any] = { "model": model.id, "messages": messages, "tools": tools, "tool_choice": tool_choice, "temperature": 0.1, "stream": True, } payload.update(provider.chat_completion_options) headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} try: async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client: async with client.stream("POST", f"{provider.base_url}/chat/completions", headers=headers, json=payload) as response: if response.status_code in {400, 404, 405, 415, 422}: detail = (await response.aread()).decode(errors="replace")[:400] raise _StreamingUnsupported(detail) if response.status_code >= 400: raise RuntimeError(f"LLM request failed ({response.status_code}): {(await response.aread()).decode(errors='replace')[:800]}") if "text/event-stream" not in str(response.headers.get("content-type") or ""): raise _StreamingUnsupported("provider returned a non-stream response") tool_calls: dict[int, dict[str, Any]] = {} async for line in response.aiter_lines(): if not line.startswith("data:"): continue raw = line[5:].strip() if raw == "[DONE]": break try: chunk = json.loads(raw) except json.JSONDecodeError: continue choices = chunk.get("choices") if isinstance(chunk, dict) else None delta = choices[0].get("delta") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {} if not isinstance(delta, dict): continue text = str(delta.get("content") or delta.get("reasoning_content") or "") if text: await on_text_delta(text) for call in delta.get("tool_calls") or []: if not isinstance(call, dict): continue index = int(call.get("index") or 0) current = tool_calls.setdefault(index, {"id": str(call.get("id") or f"call_{index}"), "type": "function", "function": {"name": "", "arguments": ""}}) if call.get("id"): current["id"] = str(call["id"]) function = call.get("function") if isinstance(call.get("function"), dict) else {} if function.get("name"): current["function"]["name"] += str(function["name"]) if function.get("arguments"): current["function"]["arguments"] += str(function["arguments"]) except _StreamingUnsupported: raise except (httpx.HTTPError, asyncio.TimeoutError) as error: raise RuntimeError(f"LLM streaming connection failed: {error}") from error return {"choices": [{"message": {"content": "", "tool_calls": [tool_calls[key] for key in sorted(tool_calls)]}}]} async def _complete_once( self, messages: list[dict[str, Any]], tools: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, required_tool_name: str | None = None, ) -> dict[str, Any]: tool_choice: str | dict[str, Any] = "auto" if required_tool_name: tool_choice = {"type": "function", "function": {"name": required_tool_name}} payload: dict[str, Any] = { "model": model.id, "messages": messages, "tools": tools, "tool_choice": tool_choice, "temperature": 0.1, } payload.update(provider.chat_completion_options) headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} # A transport failure is independent of authoring quality. Retry it # here, before the durable runner can classify the task as failed. # Keep the bound small: retries must not hide a broken provider or # leave a locked task waiting indefinitely. transport_error: Exception | None = None response: httpx.Response | None = None for attempt in range(3): try: async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client: # ``httpx`` timeouts occasionally fail to interrupt a # locally proxied socket. The coroutine deadline is the # final authority so a durable CAD task cannot remain # locked forever waiting for one author response. response = await asyncio.wait_for( client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload), timeout=self.settings.llm_timeout_s, ) if response.status_code == 400 and "thinking mode does not support this tool_choice" in response.text.lower(): retry_payload = dict(payload) retry_payload.pop("tool_choice", None) response = await asyncio.wait_for( client.post(f"{provider.base_url}/chat/completions", headers=headers, json=retry_payload), timeout=self.settings.llm_timeout_s, ) break except (httpx.HTTPError, asyncio.TimeoutError) as error: transport_error = error if attempt == 2: raise RuntimeError(f"LLM connection failed after 3 attempts: {error}") from error await asyncio.sleep(0.5 * (2**attempt)) if response is None: raise RuntimeError(f"LLM connection failed after 3 attempts: {transport_error}") if response.status_code >= 400: raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}") body = response.json() if not isinstance(body, dict) or not isinstance(body.get("choices"), list) or not body["choices"]: raise RuntimeError("LLM response contains no completion choices") return body 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": "These frozen attachments are part of the CAD request."}] for attachment in attachments: if not isinstance(attachment, dict): continue if str(attachment.get("conversation_id") or "") != conversation_id: raise ValueError("Conversation attachment metadata is invalid") relative = str(attachment.get("path") or "") if not relative: raise ValueError("Conversation attachment is missing its artifact path") 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 str(attachment.get("kind") or "") == "image": if not model.vision: raise ValueError("The selected author model does not support images. Select a vision-capable model for image attachments.") mime = str(attachment.get("mime") or "image/png") encoded = base64.b64encode(path.read_bytes()).decode("ascii") content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}}) else: extracted = str(attachment.get("extracted_path") or "") document_path = self.store.conversation_attachment_path(conversation_id, extracted) if extracted else path text = document_path.read_text(encoding="utf-8", errors="replace")[:24000] if document_path.is_file() else "" content.append({"type": "text", "text": f"Document {attachment.get('name') or path.name}:\n{text}"}) return content