364 lines
19 KiB
Python
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
|