Files
cdsl-cad/backend/app/services/agent_service.py
T
2026-09-01 16:37:27 +08:00

557 lines
30 KiB
Python

"""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_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