chore: save current work

This commit is contained in:
2026-09-01 16:37:27 +08:00
parent bc87bd6d2d
commit 97a03c290b
24 changed files with 1380 additions and 5073 deletions
+155 -48
View File
@@ -10,7 +10,7 @@ import secrets
from typing import Any
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.application.capabilities import verify_model_capability
from app.cad_agent.application.capabilities import cached_model_capability, 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
@@ -32,7 +32,10 @@ def _response_language(text: str) -> str:
_EVENT_LABELS = {
"requirements_review": "需求合同独立复核",
"image_observation": "参考图片观察",
"requirements_ready": "需求规格已就绪",
"completion_result_ready": "完成结果已就绪",
"model_protocol_check": "模型协议检查",
"action_selection": "动作选择",
"tool_call": "建模工具",
"candidate_result": "候选构建",
@@ -72,8 +75,6 @@ class AgentService:
async def resume_running_tasks(self) -> None:
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)
@@ -86,19 +87,23 @@ class AgentService:
)
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,
author_capability, reviewer_capability = await asyncio.gather(
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
role="author",
),
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
role="reviewer",
),
)
except Exception as error:
# Startup recovery must never bypass a production capability gate.
@@ -111,6 +116,14 @@ class AgentService:
retryable=True,
)
return
if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"):
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_PROTOCOL_CHECK_PENDING,
"Model protocol check is temporarily unavailable.",
retryable=True,
)
return
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
await self._park_startup_tasks(
task_ids,
@@ -119,6 +132,8 @@ class AgentService:
retryable=False,
)
return
if not self.settings.resume_running_tasks_on_startup:
return
for task_id in task_ids:
if task_id in self._autonomous_runs:
continue
@@ -213,6 +228,7 @@ class AgentService:
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
role="author",
)
reviewer_capability = await verify_model_capability(
self.v3.repository,
@@ -220,6 +236,7 @@ class AgentService:
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
role="reviewer",
)
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")
@@ -260,14 +277,15 @@ class AgentService:
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)
{"path": "/requirements/clarification", "message": str(question)}
for question in terminal.get("questions") or ()
if str(question).strip()
]
fields.extend({"path": "/requirements/review", "message": issue} for issue in terminal.get("issues") or ())
fields.extend(
{"path": "/requirements", "message": str(issue)}
for issue in terminal.get("issues") or ()
if str(issue).strip()
)
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
yield event("cad_error", {
"stage": "request",
@@ -280,35 +298,16 @@ class AgentService:
yield event("done", {})
return
resumed_task_id = selected
try:
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
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:
yield event("cad_error", {"stage": "configuration", "message": f"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: {str(error)[:500]}"})
yield event("done", {})
return
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 current and str(current.get("lifecycle") or "") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
yield event("done", {})
return
# A terminal task is immutable. A legacy task is never migrated: both
# cases intentionally create a fresh v3 task.
# A terminal task is immutable; follow-up text creates a new 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)
source_blocks, image_inputs = self._task_inputs(conversation, request)
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
except ValueError as error:
yield event("cad_error", {"stage": "request", "message": str(error)})
yield event("done", {})
@@ -316,6 +315,24 @@ class AgentService:
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."})
try:
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:
state = self.v3.repository.get_state(task_id)
if state is not None:
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
self.v3.repository.compare_and_swap(failed, events=[{
"event": "model_configuration_invalid",
"message": str(error)[:1000],
"issues": [str(error)[:1000]],
}])
terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False}
yield event("task_terminal", terminal)
yield event("cad_error", {"stage": "configuration", "message": str(error)})
yield event("done", {})
return
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
parts: list[dict[str, Any]] = []
sequence = 0
@@ -345,6 +362,18 @@ class AgentService:
await queue.put(("progress", _visible_progress("state_changed", payload)))
try:
capability_terminal = await self._ensure_task_capabilities(
task_id,
ModelIdentity(author_provider.id, author_model.id),
ModelIdentity(review_provider.id, review_model.id),
queue,
)
if capability_terminal is not None:
sequence += 1
capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()}
_upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)})
await queue.put(("task_terminal", capability_terminal))
return
async for name, payload in self.v3.workflow.run(
task_id=task_id,
author=ModelIdentity(author_provider.id, author_model.id),
@@ -393,9 +422,82 @@ class AgentService:
yield event(name, payload)
yield event("done", {})
def _task_source_blocks(self, conversation: dict[str, Any], request: str) -> list[dict[str, Any]]:
async def _ensure_task_capabilities(
self,
task_id: str,
author: ModelIdentity,
reviewer: ModelIdentity,
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None],
) -> dict[str, Any] | None:
roles = (("author", author), ("reviewer", reviewer))
cached = {
role: cached_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
provider_id=model.provider_id,
model_id=model.model_id,
role=role,
)
for role, model in roles
}
unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")]
if unsupported:
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
missing = [(role, model) for role, model in roles if cached[role] is None]
if not missing:
return None
state = self.v3.repository.get_state(task_id)
if state is None:
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False}
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
if not self.v3.repository.compare_and_swap(waiting, events=[{
"event": "model_protocol_check_pending",
"code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value,
"message": "模型协议检查中,完成后将自动继续。",
}]):
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False}
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"}))
try:
results = await asyncio.gather(*(
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=model.provider_id,
model_id=model.model_id,
role=role,
)
for role, model in missing
))
except Exception as error:
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False}
unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")]
if unavailable:
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False}
unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")]
if unsupported:
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
if not self.v3.workflow.resume(task_id):
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False}
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"}))
return None
def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]:
state = self.v3.repository.get_state(task_id)
if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
self.v3.repository.compare_and_swap(failed, events=[{
"event": "model_protocol_unsupported",
"message": message,
"issues": [message],
}])
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False}
def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
"""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()]
image_inputs: list[dict[str, str]] = []
conversation_id = str(conversation.get("conversation_id") or "")
if not conversation_id:
raise ValueError("Conversation has no identifier")
@@ -432,6 +534,11 @@ class AgentService:
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
"It is a visual reference and requires explicit visual verification."
)
image_inputs.append({
"path": str(binary_path),
"mime": str(attachment.get("mime") or "image/*"),
"sha256": expected_digest,
})
else:
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
if not text:
@@ -446,4 +553,4 @@ class AgentService:
"sha256": expected_digest,
},
})
return blocks
return blocks, image_inputs