Files
cdsl-cad/backend/app/services/agent_service.py
T

364 lines
19 KiB
Python

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