"""HTTP/SSE delivery adapter for the protocol v3 workflow.""" from __future__ import annotations import asyncio from collections.abc import AsyncIterator from hashlib import sha256 import re import secrets from typing import Any from app.cad_agent.application.workflow import ModelIdentity from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability from app.cad_agent.composition import V3Services, compose_v3 from app.cad_agent.domain.errors import ErrorCode from app.cad_agent.domain.state import TaskPhase, transition from app.models.contracts import ChatMessage from app.services.library import CdslLibrary from app.services.sse import event from app.services.storage import WorkspaceStore, now_iso from app.settings import 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 = { "image_observation": "参考图片观察", "requirements_document_ready": "需求文档已冻结", "completion_target_ready": "完成目标已冻结", "requirements_compiled": "需求合同已编译", "modeling_plan_ready": "建模计划已冻结", "completion_result_ready": "完成结果已就绪", "model_protocol_check": "模型协议检查", "action_selection": "动作选择", "tool_call": "建模工具", "candidate_result": "候选构建", "candidate_review": "候选独立复核", "final_review": "最终独立复核", "task_terminal": "生成任务", "state_changed": "任务状态", } def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]: lifecycle = str(payload.get("lifecycle") or "") result = payload.get("result") if isinstance(payload.get("result"), dict) else {} status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running") return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status} def _upsert_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 AgentService: """Delivery boundary: no CAD state transitions or provider calls live here.""" def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None: self.settings = settings self.store = store # Conversation/attachment store, not v3 CAD state. self.library = library self.v3: V3Services = compose_v3(settings) self._autonomous_runs: dict[str, asyncio.Task[None]] = {} async def resume_running_tasks(self) -> None: task_ids = self.v3.repository.running_task_ids() try: author_provider, author_model = self.settings.resolve_model(None, None) review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) except ValueError as error: await self._park_startup_tasks( task_ids, ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED, str(error), retryable=False, ) return try: author_capability, reviewer_capability = await asyncio.gather( verify_model_capability( self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=author_provider.id, model_id=author_model.id, role="author", ), verify_model_capability( self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", ), ) except Exception as error: # Startup recovery must never bypass a production capability gate. # Connectivity failures are recoverable, but they may not leave a # task falsely marked running without a worker. await self._park_startup_tasks( task_ids, ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error), retryable=True, ) return if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"): await self._park_startup_tasks( task_ids, ErrorCode.MODEL_PROTOCOL_CHECK_PENDING, "Model protocol check is temporarily unavailable.", retryable=True, ) return if not author_capability.get("supported") or not reviewer_capability.get("supported"): await self._park_startup_tasks( task_ids, ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED, "Selected author or reviewer did not pass the v3 conformance suite.", retryable=False, ) return if not self.settings.resume_running_tasks_on_startup: return for task_id in task_ids: if task_id in self._autonomous_runs: continue self._autonomous_runs[task_id] = asyncio.create_task( self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)), name=f"resume-autonomous-cad-v3-{task_id}", ) async def _park_startup_tasks( self, task_ids: list[str], error: ErrorCode, message: str, *, retryable: bool, ) -> None: """Make a failed startup gate durable instead of leaving phantom runs.""" event_name = "waiting_retry" if retryable else "failed_model_capability" for task_id in task_ids: state = self.v3.repository.get_state(task_id) if state is None or task_id in self._autonomous_runs: continue try: next_state = transition( state, "waiting_retry" if retryable else "failed", error=error, ) except ValueError: continue if self.v3.repository.compare_and_swap(next_state, events=[{ "event": event_name, "code": error.value, "message": message[:1000], "startup_recovery": True, }]): await self.v3.outbox.dispatch_pending(task_id=task_id) async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None: try: async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer): # A resumed task has no active SSE client, but its durable # state events must still leave the transactional outbox. await self.v3.outbox.dispatch_pending(task_id=task_id) finally: # Flush a final transition emitted immediately before the worker # exits, such as WAITING_RETRY or FAILED_INTERNAL. await self.v3.outbox.dispatch_pending(task_id=task_id) self._autonomous_runs.pop(task_id, None) async def cancel(self, task_id: str) -> dict[str, Any] | None: state = self.v3.repository.get_state(task_id) if state is None: return None if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED) if self.v3.repository.compare_and_swap(cancelled, events=[{ "event": "task_cancelled", "from_phase": state.phase.value, "active_revision": state.active_revision, "checkpoint_preserved": bool(state.active_revision), }]): await self.v3.outbox.dispatch_pending(task_id=task_id) # Persist the terminal transition before interrupting the coroutine. # A concurrent worker will fail its optimistic CAS instead of reviving # the task after the caller has requested cancellation. running = self._autonomous_runs.pop(task_id, None) if running and not running.done(): running.cancel() return self.v3.repository.get_task_projection(task_id) async def resume_retry(self, task_id: str) -> dict[str, Any] | None: """Explicitly restart one durably parked infrastructure retry. ``WAITING_FOR_USER`` is deliberately excluded: it requires new user input, while this endpoint is only the controlled recovery route for bounded provider/render/storage failures. """ state = self.v3.repository.get_state(task_id) if state is None: return None if state.phase.value != "WAITING_RETRY": raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint") running = self._autonomous_runs.get(task_id) if running is not None and not running.done(): raise ValueError("The CAD task is already running") author_provider, author_model = self.settings.resolve_model(None, None) review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) author_capability = await verify_model_capability( self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=author_provider.id, model_id=author_model.id, role="author", ) reviewer_capability = await verify_model_capability( self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", ) if not author_capability.get("supported") or not reviewer_capability.get("supported"): raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite") if not self.v3.workflow.resume(task_id): raise ValueError("The CAD task no longer has a recoverable retry checkpoint") self._autonomous_runs[task_id] = asyncio.create_task( self._consume_discarding( task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id), ), name=f"resume-autonomous-cad-v3-{task_id}", ) return self.v3.repository.get_task_projection(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 latest_user = next((message for message in reversed(messages) if message.role == "user"), None) if latest_user is None or not text_from_message(latest_user): yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."}) yield event("done", {}) return request = text_from_message(latest_user) conversation = self.store.ensure_conversation(conversation_id) selected = str(selected_task_id or conversation.get("current_task_id") or "") current = self.v3.repository.get_task_projection(selected) if selected else None resumed_task_id = "" if current and str(current.get("lifecycle") or "") == "waiting_for_user": state = self.v3.repository.get_state(selected) if state is not None: terminal = self.v3.workflow.waiting_for_user_terminal(selected, state) fields = [ {"path": "/requirements/clarification", "message": str(question)} for question in terminal.get("questions") or () if str(question).strip() ] fields.extend( {"path": "/requirements", "message": str(issue)} for issue in terminal.get("issues") or () if str(issue).strip() ) if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id): yield event("cad_error", { "stage": "request", "message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]), "fieldErrors": fields, "taskId": selected, "blockerType": terminal.get("blockerType"), "userActionRequired": terminal.get("userActionRequired"), }) yield event("done", {}) return resumed_task_id = selected if current and str(current.get("lifecycle") or "") == "running": yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"}) yield event("done", {}) return # A terminal task is immutable; follow-up text creates a new task. task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}" if not resumed_task_id: try: source_blocks, image_inputs = self._task_inputs(conversation, request) self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) except ValueError as error: yield event("cad_error", {"stage": "request", "message": str(error)}) yield event("done", {}) return conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id) yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."}) try: author_provider, author_model = self.settings.resolve_model(provider_id, model_id) review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) except ValueError as error: state = self.v3.repository.get_state(task_id) if state is not None: failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) self.v3.repository.compare_and_swap(failed, events=[{ "event": "model_configuration_invalid", "message": str(error)[:1000], "issues": [str(error)[:1000]], }]) terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False} yield event("task_terminal", terminal) yield event("cad_error", {"stage": "configuration", "message": str(error)}) yield event("done", {}) return queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() parts: list[dict[str, Any]] = [] sequence = 0 async def consume() -> None: nonlocal sequence async def dispatch_state_events() -> None: """Publish state notifications only through the outbox. The dispatcher preserves at-least-once semantics. The SSE event ID derives from the durable outbox row, so reconnecting clients can safely de-duplicate a delivery replay. """ nonlocal sequence for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id): sequence += 1 event_name = str(outbox_event.get("event") or "state_changed") payload = { "taskId": task_id, "eventId": f"outbox_{outbox_event['event_id']}", "sequence": sequence, "timestamp": now_iso(), "outboxEvent": event_name, "message": event_name.replace("_", " "), } await queue.put(("progress", _visible_progress("state_changed", payload))) try: capability_terminal = await self._ensure_task_capabilities( task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id), queue, ) if capability_terminal is not None: sequence += 1 capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()} _upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)}) await queue.put(("task_terminal", capability_terminal)) return async for name, payload in self.v3.workflow.run( task_id=task_id, author=ModelIdentity(author_provider.id, author_model.id), reviewer=ModelIdentity(review_provider.id, review_model.id), ): sequence += 1 decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()} _upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)}) if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed": _upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": { "stage": "generation", "message": str(decorated.get("message") or "CAD autonomous generation failed."), "tool": str(decorated.get("tool") or ""), "fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [], }}) await queue.put((name, decorated)) await dispatch_state_events() except Exception as error: sequence += 1 terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False} _upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)}) _upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}}) await queue.put(("task_terminal", terminal)) finally: self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": 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-cad-v3-{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 autonomous generation failed."), "tool": str(payload.get("tool") or ""), "fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [], }) yield event(name, payload) yield event("done", {}) async def _ensure_task_capabilities( self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity, queue: asyncio.Queue[tuple[str, dict[str, Any]] | None], ) -> dict[str, Any] | None: roles = (("author", author), ("reviewer", reviewer)) cached = { role: cached_model_capability( self.v3.repository, self.v3.workflow.runtime, provider_id=model.provider_id, model_id=model.model_id, role=role, ) for role, model in roles } unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")] if unsupported: return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}") missing = [(role, model) for role, model in roles if cached[role] is None] if not missing: return None state = self.v3.repository.get_state(task_id) if state is None: return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False} waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) if not self.v3.repository.compare_and_swap(waiting, events=[{ "event": "model_protocol_check_pending", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": "模型协议检查中,完成后将自动继续。", }]): return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False} await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"})) try: results = await asyncio.gather(*( verify_model_capability( self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=model.provider_id, model_id=model.model_id, role=role, ) for role, model in missing )) except Exception as error: return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False} unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")] if unavailable: return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False} unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")] if unsupported: return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}") if not self.v3.workflow.resume(task_id): return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False} await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"})) return None def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]: state = self.v3.repository.get_state(task_id) if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}: failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) self.v3.repository.compare_and_swap(failed, events=[{ "event": "model_protocol_unsupported", "message": message, "issues": [message], }]) return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False} def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: """Freeze message paragraphs and attachment blocks before task creation.""" blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()] image_inputs: list[dict[str, str]] = [] conversation_id = str(conversation.get("conversation_id") or "") if not conversation_id: raise ValueError("Conversation has no identifier") for attachment in conversation.get("attachments") or (): if not isinstance(attachment, dict): continue if str(attachment.get("conversation_id") or "") != conversation_id: raise ValueError("Attachment does not belong to this conversation") attachment_id = str(attachment.get("id") or "") relative_path = str(attachment.get("path") or "") expected_digest = str(attachment.get("sha256") or "") if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest): raise ValueError("Attachment metadata is incomplete") binary_path = self.store.conversation_attachment_path(conversation_id, relative_path) if not binary_path.is_file(): raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}") if sha256(binary_path.read_bytes()).hexdigest() != expected_digest: raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}") kind = str(attachment.get("kind") or "") if kind == "document": extracted_path = str(attachment.get("extracted_path") or "") if not extracted_path: raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}") text_path = self.store.conversation_attachment_path(conversation_id, extracted_path) if not text_path.is_file(): raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}") text = text_path.read_text(encoding="utf-8", errors="replace").strip() elif kind == "image": # The binary is integrity-checked above. Do not claim that a # text-only author has interpreted visual content; the source # block is still a stable, reviewable attachment reference. text = ( f"Visual attachment {attachment.get('name') or attachment_id} " f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). " "It is a visual reference and requires explicit visual verification." ) image_inputs.append({ "path": str(binary_path), "mime": str(attachment.get("mime") or "image/*"), "sha256": expected_digest, }) else: raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}") if not text: raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}") blocks.append({ "text": text, "attachment": { "attachment_id": attachment_id, "name": str(attachment.get("name") or attachment_id), "kind": kind, "mime": str(attachment.get("mime") or "application/octet-stream"), "sha256": expected_digest, }, }) return blocks, image_inputs