chore: save current work
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user