重构 改为标准化输出

This commit is contained in:
2026-09-01 14:10:23 +08:00
parent 7533b298f1
commit bc87bd6d2d
10151 changed files with 1709487 additions and 11776 deletions
+332 -448
View File
@@ -1,24 +1,24 @@
"""HTTP-facing lifecycle owner for the autonomous CDSL author."""
"""HTTP/SSE delivery adapter for the protocol v3 workflow."""
from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncIterator, Awaitable, Callable
import json
from collections.abc import AsyncIterator
from hashlib import sha256
import re
import secrets
from pathlib import Path
from typing import Any
import httpx
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.application.capabilities import 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.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
from app.settings import Settings
def text_from_message(message: ChatMessage) -> str:
@@ -32,48 +32,25 @@ def _response_language(text: str) -> str:
_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": "生成任务",
"requirements_review": "需求合同独立复核",
"action_selection": "动作选择",
"tool_call": "建模工具",
"candidate_result": "候选构建",
"candidate_review": "候选独立复核",
"final_review": "最终独立复核",
"task_terminal": "生成任务",
"state_changed": "任务状态",
}
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
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 _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:
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):
@@ -83,128 +60,180 @@ def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None
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:
"""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
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:
"""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:
task_ids = self.v3.repository.running_task_ids()
if not task_ids:
return
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 = await verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
)
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,
)
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 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
for task_id in task_ids:
if 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.",
})
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:
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),
})
next_state = transition(
state,
"waiting_retry" if retryable else "failed",
error=error,
)
except ValueError:
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)
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(
*, 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 _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:
task = self.store.read_task(task_id)
if task is 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()
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
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,
)
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,
)
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,
@@ -215,150 +244,136 @@ class AgentService:
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.
del viewer_context
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:
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)
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
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": f"/requirements/{str(item.get('draftId') or 'review')}",
"message": str(item.get("question") or item.get("reasonCode") or "Unresolved review item."),
}
for item in terminal.get("unresolved") or ()
if isinstance(item, dict)
]
fields.extend({"path": "/requirements/review", "message": issue} for issue in terminal.get("issues") or ())
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
try:
provider, model = self.settings.resolve_model(provider_id, model_id)
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:
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}"})
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)
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)
except Exception as error:
yield event("cad_error", {"stage": "configuration", "message": f"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: {str(error)[:500]}"})
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)})
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
yield event("cad_error", {"stage": "configuration", "message": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite."})
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."})
if current and str(current.get("lifecycle") or "") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
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
# A terminal task is immutable. A legacy task is never migrated: both
# cases intentionally create a fresh v3 task.
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
if not resumed_task_id:
try:
source_blocks = self._task_source_blocks(conversation, request)
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks)
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."})
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]] = []
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,
):
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
decorated = dict(payload)
decorated.setdefault("taskId", task_id)
decorated.setdefault("eventId", f"{task_id}_{sequence}_{name}")
decorated.update({
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(),
})
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)})
"outboxEvent": event_name,
"message": event_name.replace("_", " "),
}
await queue.put(("progress", _visible_progress("state_changed", payload)))
try:
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:
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))
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:
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.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-cdsl-{task_id}")
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)
@@ -369,197 +384,66 @@ class AgentService:
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("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", {})
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 ""
def _task_source_blocks(self, conversation: dict[str, Any], request: str) -> list[dict[str, Any]]:
"""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()]
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:
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("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}"}})
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."
)
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
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