566 lines
29 KiB
Python
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
|