Files
cdsl-cad/backend/app/services/agent_service.py
T
2026-08-31 14:16:08 +08:00

566 lines
29 KiB
Python

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