重构 改为标准化输出
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user