"""HTTP-facing lifecycle owner for the autonomous CDSL author.""" from __future__ import annotations import asyncio import base64 from collections.abc import AsyncIterator 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 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 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 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) queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete) assistant_id = f"assistant_{secrets.token_hex(8)}" async def consume() -> None: assistant_parts: list[dict[str, Any]] = [] 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, ): if name == "cad_result": assistant_parts.append({"type": "data-cad-result", "data": payload}) elif name == "agent_thinking": visible = str(payload.get("message") or "") if visible: assistant_parts.append({"type": "text", "text": visible}) elif name == "task_terminal": terminal = str(payload.get("lifecycle") or "") if terminal == "failed": assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}}) await queue.put((name, payload)) except Exception as error: self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)}) terminal = "failed" payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)} assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}}) 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: item = await queue.get() if item is None: break name, payload = item if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed": yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}) elif name == "agent_thinking": yield event("text_delta", {"text": str(payload.get("message") or "")}) 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, ) -> 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