"""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 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 = { "requirements_review": "需求合同独立复核", "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() if not task_ids: return 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 = await verify_model_capability( self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=author_provider.id, model_id=author_model.id, ) 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, ) 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 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 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, ) 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, ) 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": f"/requirements/{str(item.get('draftId') or 'review')}", "message": str(item.get("question") or item.get("reasonCode") or "Unresolved review item."), } for item in terminal.get("unresolved") or () if isinstance(item, dict) ] fields.extend({"path": "/requirements/review", "message": issue} for issue in terminal.get("issues") or ()) 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 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: yield event("cad_error", {"stage": "configuration", "message": str(error)}) yield event("done", {}) return try: 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) 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) except Exception as error: yield event("cad_error", {"stage": "configuration", "message": f"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: {str(error)[:500]}"}) yield event("done", {}) return if not author_capability.get("supported") or not reviewer_capability.get("supported"): yield event("cad_error", {"stage": "configuration", "message": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite."}) yield event("done", {}) return 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. A legacy task is never migrated: both # cases intentionally create a fresh v3 task. task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}" if not resumed_task_id: try: source_blocks = self._task_source_blocks(conversation, request) self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks) 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."}) 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: 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", {}) def _task_source_blocks(self, conversation: dict[str, Any], request: str) -> list[dict[str, Any]]: """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()] 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." ) 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