1800 lines
103 KiB
Python
1800 lines
103 KiB
Python
"""LLM turn coordinator for protocol v3.
|
|
|
|
It selects only which schema is visible from the persisted state. It never
|
|
derives a CAD operation from requirements; that choice is always an author
|
|
tool call handled by ``ActionCommandHandler``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
from hashlib import sha256
|
|
from dataclasses import dataclass
|
|
from typing import Any, AsyncIterator, TypeVar
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
|
from app.cad_agent.application.llm_contracts import (
|
|
CandidateReview, CompiledRequirementsSpec, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, MarkdownDocument, NextAction,
|
|
RollbackCheckpoint, StatelessCandidateReview,
|
|
StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint,
|
|
StatelessTopologyRequest, TopologyRequest,
|
|
canonical_json_object, canonical_validate, canonical_validate_schema,
|
|
sanitize_compiled_requirements_arguments,
|
|
stateless_final_review_schema,
|
|
stateless_next_action_schema, stateless_rollback_checkpoint_schema,
|
|
raw_arguments_hash, validate_one_tool_call,
|
|
)
|
|
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
|
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
|
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
|
from app.cad_agent.domain.operation_contract import fragment_schema
|
|
from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition
|
|
from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository
|
|
|
|
|
|
T = TypeVar("T", bound=BaseModel)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ModelIdentity:
|
|
provider_id: str
|
|
model_id: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WorkflowConfig:
|
|
max_turns: int
|
|
format_error_limit: int
|
|
author_fallbacks: tuple[ModelIdentity, ...] = ()
|
|
max_author_turns: int | None = None
|
|
max_reviewer_turns: int | None = None
|
|
max_model_calls: int | None = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ModelCallBudget:
|
|
"""Task-scoped model-call caps, including calls from a prior resume."""
|
|
|
|
max_author_turns: int | None
|
|
max_reviewer_turns: int | None
|
|
max_model_calls: int | None
|
|
author_calls: int = 0
|
|
reviewer_calls: int = 0
|
|
|
|
@classmethod
|
|
def from_usage(cls, config: WorkflowConfig, records: list[dict[str, Any]]) -> "_ModelCallBudget":
|
|
reviewer_calls = sum(1 for record in records if record.get("role") == "reviewer")
|
|
return cls(
|
|
max_author_turns=config.max_author_turns,
|
|
max_reviewer_turns=config.max_reviewer_turns,
|
|
max_model_calls=config.max_model_calls,
|
|
author_calls=len(records) - reviewer_calls,
|
|
reviewer_calls=reviewer_calls,
|
|
)
|
|
|
|
@property
|
|
def total_calls(self) -> int:
|
|
return self.author_calls + self.reviewer_calls
|
|
|
|
def exhausted(self, actor: str) -> bool:
|
|
return (
|
|
(actor == "author" and self.max_author_turns is not None and self.author_calls >= self.max_author_turns)
|
|
or (actor == "reviewer" and self.max_reviewer_turns is not None and self.reviewer_calls >= self.max_reviewer_turns)
|
|
or (self.max_model_calls is not None and self.total_calls >= self.max_model_calls)
|
|
)
|
|
|
|
def record_attempt(self, actor: str) -> None:
|
|
if actor == "reviewer":
|
|
self.reviewer_calls += 1
|
|
else:
|
|
self.author_calls += 1
|
|
|
|
def payload(self) -> dict[str, int | None]:
|
|
return {
|
|
"author_calls": self.author_calls,
|
|
"reviewer_calls": self.reviewer_calls,
|
|
"total_calls": self.total_calls,
|
|
"max_author_turns": self.max_author_turns,
|
|
"max_reviewer_turns": self.max_reviewer_turns,
|
|
"max_model_calls": self.max_model_calls,
|
|
}
|
|
|
|
|
|
class WorkflowCoordinator:
|
|
def __init__(
|
|
self,
|
|
config: WorkflowConfig,
|
|
repository: TaskRepository,
|
|
artifacts: ArtifactStore,
|
|
runtime: CadRuntime,
|
|
model_gateway: ModelGateway,
|
|
review_gateway: ReviewGateway,
|
|
requirements: RequirementsCommandHandler,
|
|
actions: ActionCommandHandler,
|
|
) -> None:
|
|
self.config = config
|
|
self.repository = repository
|
|
self.artifacts = artifacts
|
|
self.runtime = runtime
|
|
self.model_gateway = model_gateway
|
|
self.review_gateway = review_gateway
|
|
self.requirements = requirements
|
|
self.actions = actions
|
|
|
|
def create_task(
|
|
self,
|
|
task_id: str,
|
|
request: str,
|
|
*,
|
|
source_blocks: list[dict[str, Any]] | None = None,
|
|
image_inputs: list[dict[str, str]] | None = None,
|
|
) -> TaskState:
|
|
# The immutable source artifact is safe to create before SQLite state:
|
|
# an interrupted creation leaves only an unreferenced directory, never
|
|
# a runnable task without its source index.
|
|
self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
|
return self.repository.create_task(task_id, request)
|
|
|
|
def resume(self, task_id: str) -> bool:
|
|
state = self.repository.get_state(task_id)
|
|
if state is None or state.phase != TaskPhase.WAITING_RETRY:
|
|
return False
|
|
event = retry_resume_event(state)
|
|
if not event:
|
|
return False
|
|
next_state = transition(state, event)
|
|
return self.repository.compare_and_swap(next_state, events=[
|
|
{
|
|
"event": "workflow_resumed",
|
|
"from_phase": state.phase.value,
|
|
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
|
|
"to_phase": next_state.phase.value,
|
|
},
|
|
])
|
|
|
|
def resume_with_user_clarification(self, task_id: str, clarification: str, *, message_id: str) -> bool:
|
|
"""Resume a requirements pause on the same task with durable input."""
|
|
state = self.repository.get_state(task_id)
|
|
if state is None or state.phase != TaskPhase.WAITING_FOR_USER:
|
|
return False
|
|
clarification_request = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None
|
|
if not isinstance(clarification_request, dict) or not str(clarification_request.get("question") or "").strip():
|
|
return False
|
|
text = clarification.strip()
|
|
if not text:
|
|
return False
|
|
digest = sha256(f"{message_id}:{text}".encode("utf-8")).hexdigest()[:16]
|
|
clarification_path = f"documents/user-clarification-{digest}.json"
|
|
try:
|
|
self.artifacts.write_json_once(task_id, clarification_path, {
|
|
"schema_version": "cad.user-clarification.v1",
|
|
"task_id": task_id,
|
|
"message_id": message_id,
|
|
"text": text,
|
|
})
|
|
except OSError:
|
|
return False
|
|
resumed = transition(state, "requirements_clarified", clarification_path="")
|
|
return self.repository.compare_and_swap(resumed, events=[{
|
|
"event": "user_clarification_received",
|
|
"message_id": message_id,
|
|
"clarification_path": clarification_path,
|
|
"response_sha256": sha256(text.encode("utf-8")).hexdigest(),
|
|
}])
|
|
|
|
async def run(self, *, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
|
feedback: list[dict[str, Any]] = []
|
|
format_errors: dict[str, int] = {}
|
|
transport_attempted: set[str] = set()
|
|
# Observations are request-scoped, read-only facts. A restarted run
|
|
# deliberately has to fetch them again before authoring a selector-
|
|
# bound feature.
|
|
action_observations: dict[str, set[str]] = {}
|
|
active_author = author
|
|
max_turns = self.config.max_turns
|
|
call_budget = _ModelCallBudget.from_usage(
|
|
self.config,
|
|
self.repository.usage_summary(task_id).get("records", []),
|
|
)
|
|
try:
|
|
initial = self.repository.get_state(task_id)
|
|
if initial is not None:
|
|
referenced = {initial.candidate_stage_id} if initial.candidate_stage_id else set()
|
|
# Candidate rejection/build-failure evidence is itself an
|
|
# immutable repair input. Once the state transition clears
|
|
# ``candidate_stage_id``, retain that stage through restart
|
|
# based on its committed ledger reference.
|
|
referenced.update(
|
|
str(event["stage_id"])
|
|
for event in self.repository.ledger_events(task_id)
|
|
if event.get("event") in {
|
|
"candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", "candidate_recovered_rejected",
|
|
}
|
|
and isinstance(event.get("stage_id"), str)
|
|
and event["stage_id"]
|
|
)
|
|
try:
|
|
self.artifacts.recover_staged_candidates(task_id, referenced)
|
|
except Exception as error:
|
|
yield self._storage_failure(task_id, str(error))
|
|
return
|
|
for _turn in range(max_turns):
|
|
try:
|
|
self._sync_action_ledger(task_id)
|
|
except Exception as error:
|
|
yield self._storage_failure(task_id, str(error))
|
|
return
|
|
state = self.repository.get_state(task_id)
|
|
if state is None:
|
|
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."}
|
|
return
|
|
if state.phase == TaskPhase.COMPLETED:
|
|
yield "task_terminal", self._projected_terminal(task_id, state)
|
|
return
|
|
if state.phase == TaskPhase.CANCELLED:
|
|
yield "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "cancelled",
|
|
"revisionId": state.active_revision,
|
|
"code": ErrorCode.CANCELLED.value,
|
|
}
|
|
return
|
|
if state.phase == TaskPhase.WAITING_FOR_USER:
|
|
yield "task_terminal", self.waiting_for_user_terminal(task_id, state)
|
|
return
|
|
if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY}:
|
|
yield "task_terminal", self._projected_terminal(task_id, state)
|
|
return
|
|
if state.phase == TaskPhase.CANDIDATE_BUILDING:
|
|
recovered = self.actions.recover_candidate_build(task_id)
|
|
yield "candidate_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
|
|
if isinstance(recovered, Rejected):
|
|
yield self._service_failure(task_id, state, recovered.error)
|
|
return
|
|
continue
|
|
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
|
|
image_paths = self.artifacts.source_image_paths(task_id)
|
|
if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None:
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("reviewer")
|
|
observation = await self._observe_images(task_id, reviewer, image_paths)
|
|
if isinstance(observation, WorkflowError):
|
|
if observation.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
observation = WorkflowError(
|
|
ErrorCode.REVIEW_SERVICE_UNAVAILABLE,
|
|
"Image observation did not return the required structured format.",
|
|
field_errors=observation.field_errors,
|
|
retryable=True,
|
|
)
|
|
yield self._service_failure(task_id, state, observation)
|
|
return
|
|
try:
|
|
self.artifacts.write_json_once(task_id, "documents/image-observation.json", {
|
|
"schema_version": "cad.image-observation.v3",
|
|
**observation.model_dump(mode="json"),
|
|
})
|
|
except OSError as error:
|
|
yield self._storage_failure(task_id, str(error))
|
|
return
|
|
observed_state = transition(state, "image_observed")
|
|
self.repository.compare_and_swap(observed_state, events=[{
|
|
"event": "image_observation_ready",
|
|
"path": "documents/image-observation.json",
|
|
"image_count": len(image_paths),
|
|
}])
|
|
yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"}
|
|
continue
|
|
document_schema = self.requirements.document_schema()
|
|
tools = [self._tool("write_requirements_document", document_schema)]
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("author")
|
|
result = await self._author_turn(task_id, active_author, tools, feedback)
|
|
if isinstance(result, WorkflowError):
|
|
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
|
|
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
name, raw, usage = result
|
|
validation = canonical_validate(raw, MarkdownDocument)
|
|
dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None
|
|
if dynamic_error is not None:
|
|
validation = dynamic_error
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
invocation_id = self._invocation_id(task_id)
|
|
command = self.requirements.submit_requirements_document(task_id, validation, invocation_id=invocation_id)
|
|
if isinstance(command, Rejected):
|
|
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
event_payload = self._event(task_id, name, self._result_payload(command), "success", usage)
|
|
event_payload["markdown"] = validation.markdown
|
|
yield "requirements_document_ready", event_payload
|
|
feedback = []
|
|
continue
|
|
if state.phase in {TaskPhase.DRAFTING_COMPLETION_TARGET, TaskPhase.DRAFTING_MODELING_PLAN}:
|
|
document_schema = self.requirements.document_schema()
|
|
tool_name = "write_completion_target" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "write_modeling_plan"
|
|
tools = [self._tool(tool_name, document_schema)]
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("author")
|
|
result = await self._author_turn(task_id, active_author, tools, feedback)
|
|
if isinstance(result, WorkflowError):
|
|
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
|
|
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
name, raw, usage = result
|
|
validation = canonical_validate(raw, MarkdownDocument)
|
|
dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None
|
|
if dynamic_error is not None:
|
|
validation = dynamic_error
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool=tool_name)
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
command = self.requirements.submit_completion_target(task_id, validation, invocation_id=self._invocation_id(task_id)) if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else self.requirements.submit_modeling_plan(task_id, validation, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(command, Rejected):
|
|
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool=tool_name)
|
|
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
event_payload = self._event(task_id, name, self._result_payload(command), "success", usage)
|
|
event_payload["markdown"] = validation.markdown
|
|
yield ("completion_target_ready" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "modeling_plan_ready"), event_payload
|
|
feedback = []
|
|
continue
|
|
if state.phase == TaskPhase.COMPILING_REQUIREMENTS:
|
|
compiler_schema = self.requirements.compiler_schema(task_id)
|
|
tools = [self._tool("compile_requirements_spec", compiler_schema)]
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("author")
|
|
result = await self._author_turn(task_id, active_author, tools, feedback)
|
|
if isinstance(result, WorkflowError):
|
|
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
|
|
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
name, raw, usage = result
|
|
sanitized_raw = sanitize_compiled_requirements_arguments(raw)
|
|
if isinstance(sanitized_raw, WorkflowError):
|
|
validation: CompiledRequirementsSpec | WorkflowError = sanitized_raw
|
|
else:
|
|
validation = canonical_validate(sanitized_raw, CompiledRequirementsSpec)
|
|
dynamic_error = canonical_validate_schema(sanitized_raw, compiler_schema) if not isinstance(validation, WorkflowError) and isinstance(sanitized_raw, str) else None
|
|
if dynamic_error is not None:
|
|
validation = dynamic_error
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool="compile_requirements_spec")
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
command = self.requirements.submit_compiled_spec(task_id, validation, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(command, Rejected):
|
|
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="compile_requirements_spec")
|
|
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage)
|
|
feedback = []
|
|
continue
|
|
if state.phase == TaskPhase.AWAITING_ACTION:
|
|
contract = self._requirements_contract(task_id, state) or {}
|
|
requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")]
|
|
action_schema = stateless_next_action_schema(list(self.actions.available_atomic_ids(task_id, state)))
|
|
tools = self._recovery_tools(task_id, state)
|
|
if not tools:
|
|
if self._can_complete(task_id, state):
|
|
tools = [self._tool("complete_task", EmptyCommand)]
|
|
elif self.actions.repair_action_ready(task_id, state):
|
|
tools = [self._tool("propose_next_action", action_schema)]
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("author")
|
|
result = await self._author_turn(task_id, active_author, tools, feedback)
|
|
if isinstance(result, WorkflowError):
|
|
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
|
|
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
name, raw, usage = result
|
|
if name == "complete_task":
|
|
validation = canonical_validate(raw, EmptyCommand)
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
command = self.actions.complete_task(task_id, invocation_id=self._invocation_id(task_id))
|
|
elif name == "record_geometry_conclusion":
|
|
validation = canonical_validate(raw, StatelessGeometryConclusion)
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
validation = GeometryConclusion(
|
|
working_head=state.working_head,
|
|
evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)),
|
|
root_cause=validation.root_cause,
|
|
decision=validation.decision,
|
|
corrective_intent=validation.corrective_intent,
|
|
)
|
|
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
|
|
elif name == "rollback_checkpoint":
|
|
rollback_schema = stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state)))
|
|
validation = canonical_validate(raw, StatelessRollbackCheckpoint)
|
|
dynamic_error = canonical_validate_schema(raw, rollback_schema) if not isinstance(validation, WorkflowError) else None
|
|
if dynamic_error is not None:
|
|
validation = dynamic_error
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
validation = RollbackCheckpoint(working_head=state.working_head, checkpoint_token=validation.checkpoint_token, reason=validation.reason)
|
|
command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id))
|
|
else:
|
|
validation = canonical_validate(raw, StatelessNextAction)
|
|
dynamic_error = canonical_validate_schema(raw, action_schema) if not isinstance(validation, WorkflowError) else None
|
|
if dynamic_error is not None:
|
|
validation = dynamic_error
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
validation = NextAction(
|
|
working_head=state.working_head,
|
|
intent=validation.intent,
|
|
requirement_ids=requirement_ids,
|
|
atomic_id=validation.operation,
|
|
expected_change=validation.expected_change,
|
|
)
|
|
command = self.actions.propose_next_action(task_id, validation, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(command, Rejected):
|
|
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
|
yield "action_selection", self._event(task_id, name, command.error.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
yield "action_selection", self._event(task_id, name, self._result_payload(command), "success", usage)
|
|
feedback = []
|
|
continue
|
|
if state.phase == TaskPhase.ACTION_PENDING:
|
|
observed = action_observations.setdefault(state.working_head, set())
|
|
tools = self._recovery_tools(task_id, state)
|
|
if not tools and self.actions.repair_action_ready(task_id, state):
|
|
tools = self._action_tools(task_id, state, observed)
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("author")
|
|
result = await self._author_turn(task_id, active_author, tools, feedback)
|
|
if isinstance(result, WorkflowError):
|
|
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
|
|
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
name, raw, usage = result
|
|
if name == "record_geometry_conclusion":
|
|
validation = canonical_validate(raw, StatelessGeometryConclusion)
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
validation = GeometryConclusion(
|
|
working_head=state.working_head,
|
|
evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)),
|
|
root_cause=validation.root_cause,
|
|
decision=validation.decision,
|
|
corrective_intent=validation.corrective_intent,
|
|
)
|
|
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(command, Rejected):
|
|
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
yield "tool_call", self._event(task_id, name, self._result_payload(command), "success", usage)
|
|
feedback = []
|
|
continue
|
|
if name == "inspect_topology":
|
|
validation = canonical_validate(raw, StatelessTopologyRequest)
|
|
if isinstance(validation, WorkflowError):
|
|
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
payload = self._topology_payload(task_id, state, validation.kind, validation.limit)
|
|
observed.add("topology")
|
|
yield "tool_call", self._event(task_id, name, payload, "success", usage)
|
|
feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:]
|
|
continue
|
|
fragment = canonical_json_object(raw)
|
|
if isinstance(fragment, WorkflowError):
|
|
validation_error = fragment
|
|
terminal = self._format_failure(task_id, state, name, validation_error, format_errors, feedback)
|
|
yield "tool_call", self._event(task_id, name, validation_error.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
command = self.actions.submit_cdsl_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(command, Rejected):
|
|
# A candidate build may reject and advance back to
|
|
# ACTION_PENDING. That is repair work, not a no-side-
|
|
# effect schema/state retry.
|
|
after = self.repository.get_state(task_id)
|
|
if after is not None and after.version != state.version:
|
|
feedback = [self._feedback(command.error)]
|
|
yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage)
|
|
continue
|
|
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
|
yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage)
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
yield "candidate_result", self._event(task_id, name, self._result_payload(command), "success", usage)
|
|
feedback = []
|
|
continue
|
|
if state.phase == TaskPhase.CANDIDATE_REVIEW:
|
|
recovered = self.actions.recover_candidate_review(task_id)
|
|
if recovered is not None:
|
|
yield "candidate_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
|
|
if isinstance(recovered, Rejected):
|
|
yield self._service_failure(task_id, state, recovered.error)
|
|
return
|
|
continue
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("reviewer")
|
|
review = await self._review_candidate(task_id, reviewer, state, feedback)
|
|
if isinstance(review, WorkflowError):
|
|
if review.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
terminal = self._format_failure(task_id, state, "review_candidate", review, format_errors, feedback, actor="reviewer")
|
|
yield "candidate_review", {"taskId": task_id, "status": "error", "result": review.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
yield self._service_failure(task_id, state, review)
|
|
return
|
|
candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") or {}
|
|
action = state.pending_action
|
|
if action is None:
|
|
yield self._storage_failure(task_id, "Candidate action is unavailable during review.")
|
|
return
|
|
coverage = []
|
|
for item in candidate.get("claim_results") or ():
|
|
if not isinstance(item, dict):
|
|
continue
|
|
status = str(item.get("status") or "pending")
|
|
coverage.append({
|
|
"claim_id": str(item.get("claim_id") or ""),
|
|
"status": status if status in {"pass", "pending", "fail", "not_applicable"} else "fail",
|
|
"evidence_refs": [],
|
|
})
|
|
review = CandidateReview(
|
|
candidate_id=state.candidate_id,
|
|
working_head=action.working_head,
|
|
verdict=review.verdict,
|
|
claim_coverage=coverage,
|
|
evidence=review.evidence,
|
|
issues=review.issues,
|
|
)
|
|
command = self.actions.record_candidate_review(task_id, review, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(command, Rejected):
|
|
terminal = self._model_rejection_or_service_failure(task_id, state, "review_candidate", command.error, format_errors, feedback, actor="reviewer")
|
|
yield "candidate_review", {"taskId": task_id, "status": "error", "result": command.error.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
yield "candidate_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
|
continue
|
|
if state.phase == TaskPhase.FINAL_VALIDATION:
|
|
recovered = self.actions.recover_final_review(task_id)
|
|
if recovered is not None:
|
|
if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed":
|
|
completed_state = self.repository.get_state(task_id)
|
|
if completed_state is not None:
|
|
try:
|
|
self._ensure_recovered_completion_result(task_id, completed_state)
|
|
except OSError as error:
|
|
yield self._storage_failure(task_id, str(error))
|
|
return
|
|
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
|
|
yield "final_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
|
|
if isinstance(recovered, Rejected):
|
|
yield self._service_failure(task_id, state, recovered.error)
|
|
return
|
|
continue
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("reviewer")
|
|
review = await self._review_final(task_id, reviewer, state, feedback)
|
|
if isinstance(review, WorkflowError):
|
|
if review.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
terminal = self._format_failure(task_id, state, "review_final", review, format_errors, feedback, actor="reviewer")
|
|
yield "final_review", {"taskId": task_id, "status": "error", "result": review.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
yield self._service_failure(task_id, state, review)
|
|
return
|
|
facts = self.actions._facts(task_id, state.active_revision)
|
|
claim_results = self.actions._evaluate_claims(task_id, facts)
|
|
visual_decisions = iter(review.visual_claims)
|
|
coverage = []
|
|
for item in claim_results:
|
|
if item.get("deterministic"):
|
|
status = str(item.get("status") or "fail")
|
|
status = status if status in {"pass", "pending", "fail", "not_applicable"} else "fail"
|
|
else:
|
|
status = next(visual_decisions).status
|
|
coverage.append({"claim_id": str(item.get("claim_id") or ""), "status": status, "evidence_refs": []})
|
|
stateless_review = review
|
|
review = FinalReview(
|
|
working_head=state.working_head,
|
|
verdict=review.verdict,
|
|
claim_coverage=coverage,
|
|
evidence=review.evidence,
|
|
issues=review.issues,
|
|
)
|
|
will_complete = (
|
|
stateless_review.verdict == "pass"
|
|
and all(item.get("status") == "pass" for item in claim_results if item.get("deterministic"))
|
|
and all(item.status == "pass" for item in stateless_review.visual_claims)
|
|
)
|
|
if will_complete:
|
|
try:
|
|
self.requirements.write_completion_result(
|
|
task_id,
|
|
state,
|
|
claim_results=claim_results,
|
|
review=stateless_review.model_dump(mode="json"),
|
|
)
|
|
except OSError as error:
|
|
yield self._storage_failure(task_id, str(error))
|
|
return
|
|
command = self.actions.record_final_review(task_id, review, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(command, Rejected):
|
|
terminal = self._model_rejection_or_service_failure(task_id, state, "review_final", command.error, format_errors, feedback, actor="reviewer")
|
|
yield "final_review", {"taskId": task_id, "status": "error", "result": command.error.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
if isinstance(command, Accepted) and command.payload.get("status") == "completed":
|
|
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
|
|
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
|
continue
|
|
state = self.repository.get_state(task_id)
|
|
terminal = self._best_effort_terminal(task_id, state, ErrorCode.NO_PROGRESS_LIMIT, "The workflow reached its bounded turn limit.") if state else None
|
|
if terminal:
|
|
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
|
|
yield terminal
|
|
return
|
|
if state:
|
|
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
|
|
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}])
|
|
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Workflow exceeded its finite turn limit."}
|
|
except OSError as error:
|
|
yield self._storage_failure(task_id, str(error))
|
|
except Exception as error:
|
|
state = self.repository.get_state(task_id)
|
|
terminal = self._best_effort_terminal(task_id, state, ErrorCode.FAILED_INTERNAL, str(error)[:1000]) if state else None
|
|
if terminal:
|
|
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
|
|
yield terminal
|
|
return
|
|
if state and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
|
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
|
|
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": str(error)[:1000]}])
|
|
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]}
|
|
finally:
|
|
# The database ledger is authoritative. A failed mirror write is
|
|
# retried by the next active workflow pass; do not replace a
|
|
# completed/cancelled durable result with a filesystem exception.
|
|
try:
|
|
self._sync_action_ledger(task_id)
|
|
except Exception:
|
|
pass
|
|
|
|
def _sync_action_ledger(self, task_id: str) -> None:
|
|
self.artifacts.sync_action_ledger(task_id, self.repository.ledger_events(task_id))
|
|
|
|
def _call_budget_terminal(
|
|
self,
|
|
task_id: str,
|
|
state: TaskState,
|
|
budget: _ModelCallBudget,
|
|
*,
|
|
actor: str,
|
|
) -> tuple[str, dict[str, Any]] | None:
|
|
if not budget.exhausted(actor):
|
|
return None
|
|
details = budget.payload()
|
|
details["next_actor"] = actor
|
|
terminal = self._best_effort_terminal(
|
|
task_id,
|
|
state,
|
|
ErrorCode.CALL_BUDGET_EXHAUSTED,
|
|
"Configured model-call budget is exhausted; publishing the last executable checkpoint.",
|
|
budget=details,
|
|
)
|
|
if terminal:
|
|
return terminal
|
|
failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED)
|
|
self.repository.compare_and_swap(failed, events=[{
|
|
"event": "call_budget_exhausted",
|
|
"code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
|
|
"message": "Configured model-call budget is exhausted before the workflow converged.",
|
|
**details,
|
|
}])
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "failed",
|
|
"code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
|
|
"message": "Configured model-call budget is exhausted before the workflow converged.",
|
|
"budget": details,
|
|
"blockerType": "call_budget_exhausted",
|
|
"userActionRequired": False,
|
|
}
|
|
|
|
def _best_effort_terminal(
|
|
self,
|
|
task_id: str,
|
|
state: TaskState,
|
|
reason: ErrorCode,
|
|
message: str,
|
|
*,
|
|
budget: dict[str, Any] | None = None,
|
|
) -> tuple[str, dict[str, Any]] | None:
|
|
if not state.active_revision or state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
|
return None
|
|
claim_results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision))
|
|
visual_claims = [
|
|
{"status": "not_reviewed", "evidence": "Final review was not reached before bounded completion."}
|
|
for item in claim_results
|
|
if not item.get("deterministic")
|
|
]
|
|
try:
|
|
self.requirements.write_completion_result(
|
|
task_id,
|
|
state,
|
|
claim_results=claim_results,
|
|
review={"visual_claims": visual_claims},
|
|
)
|
|
except OSError:
|
|
return None
|
|
completed = self.actions.finalize_best_effort(task_id, reason=reason, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(completed, Rejected):
|
|
return None
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "completed",
|
|
"revisionId": state.active_revision,
|
|
"code": ErrorCode.BEST_EFFORT_COMPLETED.value,
|
|
"message": message,
|
|
"issues": completed.payload.get("issues") or [],
|
|
"verificationStatus": "completed_with_risks",
|
|
"verificationWarnings": completed.payload.get("issues") or [],
|
|
"completionResultPath": "completion-result.md",
|
|
"budget": budget or {},
|
|
"userActionRequired": False,
|
|
}
|
|
|
|
def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]:
|
|
"""Park a nonterminal task when a durable artifact operation fails."""
|
|
state = self.repository.get_state(task_id)
|
|
if state is not None and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}:
|
|
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
|
self.repository.compare_and_swap(waiting, events=[{
|
|
"event": "waiting_retry",
|
|
"code": ErrorCode.STORAGE_FAILURE.value,
|
|
"message": message[:1000],
|
|
}])
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "waiting_retry",
|
|
"code": ErrorCode.STORAGE_FAILURE.value,
|
|
"message": message[:1000],
|
|
}
|
|
lifecycle = state.phase.value.lower() if state is not None else "failed"
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": lifecycle,
|
|
"code": ErrorCode.STORAGE_FAILURE.value,
|
|
"message": message[:1000],
|
|
}
|
|
|
|
async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, int]] | WorkflowError:
|
|
if len(tools) != 1:
|
|
raise RuntimeError("Workflow state must expose exactly one author tool.")
|
|
tool = tools[0]
|
|
name = str((tool.get("function") or {}).get("name") or "")
|
|
if not name:
|
|
raise RuntimeError("Workflow exposed an unnamed author tool.")
|
|
messages = self._author_context(task_id, feedback)
|
|
try:
|
|
response = await self.model_gateway.call_tool(
|
|
messages=messages, tool=tool, provider_id=author.provider_id,
|
|
model_id=author.model_id, required_tool_name=name,
|
|
)
|
|
except AdapterUnavailable as error:
|
|
self.repository.record_usage(task_id, {
|
|
"prompt_tokens": 0,
|
|
"completion_tokens": 0,
|
|
"total_tokens": 0,
|
|
"usage_available": False,
|
|
"context_chars": len(json.dumps(messages, ensure_ascii=False)),
|
|
"tool": name,
|
|
"provider_id": author.provider_id,
|
|
"model_id": author.model_id,
|
|
"retry_reason": "provider_unavailable",
|
|
"cache_hit": False,
|
|
})
|
|
return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True)
|
|
call = validate_one_tool_call(response["tool_calls"], name)
|
|
if isinstance(call, WorkflowError):
|
|
self._record_rejected_tool_calls(
|
|
task_id,
|
|
actor="author",
|
|
expected_tool=name,
|
|
tool_calls=response["tool_calls"],
|
|
schema=(tool.get("function") or {}).get("parameters"),
|
|
)
|
|
return call
|
|
_name, raw = call
|
|
self._record_tool_audit(
|
|
task_id,
|
|
actor="author",
|
|
tool=name,
|
|
raw_arguments=raw,
|
|
schema=(tool.get("function") or {}).get("parameters"),
|
|
)
|
|
usage = {
|
|
**response["usage"],
|
|
"context_chars": len(json.dumps(messages, ensure_ascii=False)),
|
|
"tool": name,
|
|
"provider_id": author.provider_id,
|
|
"model_id": author.model_id,
|
|
"raw_arguments_hash": raw_arguments_hash(raw),
|
|
"retry_reason": "",
|
|
"cache_hit": False,
|
|
}
|
|
self.repository.record_usage(task_id, usage)
|
|
return name, raw, usage
|
|
|
|
async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessCandidateReview | WorkflowError:
|
|
candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json")
|
|
action = state.pending_action
|
|
if not isinstance(candidate, dict) or action is None:
|
|
return WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review facts are unavailable.", retryable=True)
|
|
return await self._review_tool(task_id, reviewer, "review_candidate", StatelessCandidateReview, {
|
|
"requirements": self._public_requirements(self._requirements_contract(task_id, state)),
|
|
"action": {"intent": action.intent, "expected_change": action.expected_change, "operation": action.atomic_id},
|
|
"candidate_facts": self._public_candidate(candidate),
|
|
"render_manifest": candidate.get("render_manifest") or {},
|
|
"instruction": "Review only whether the current checkpoint correctly performs the stated action. Deterministic facts are authoritative. Do not return task, action, candidate, requirement, claim, revision, head, or evidence identifiers.",
|
|
}, feedback=feedback)
|
|
|
|
async def _observe_images(self, task_id: str, reviewer: ModelIdentity, image_paths: list[str]) -> ImageObservation | WorkflowError:
|
|
return await self._review_tool(task_id, reviewer, "observe_images", ImageObservation, {
|
|
"source_requirements": self.artifacts.read_source_requirements(task_id),
|
|
"reference_image_paths": image_paths,
|
|
"instruction": (
|
|
"Inspect every supplied reference image once. Describe visible part geometry, view directions, readable dimensions, holes and profiles, confidence, assumptions, and uncertainties. "
|
|
"Do not create CAD operations and do not return attachment or runtime identifiers."
|
|
),
|
|
})
|
|
|
|
async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessFinalReview | WorkflowError:
|
|
facts = self.actions._facts(task_id, state.active_revision)
|
|
results = self.actions._evaluate_claims(task_id, facts)
|
|
visual_claims = [item for item in results if not item.get("deterministic")]
|
|
return await self._review_tool(task_id, reviewer, "review_final", StatelessFinalReview, {
|
|
"source_requirements": self.artifacts.read_source_requirements(task_id),
|
|
"requirements": self._public_requirements(self._requirements_contract(task_id, state)),
|
|
"deterministic_results": [self._public_claim_result(item) for item in results if item.get("deterministic")],
|
|
"visual_claims": [self._public_claim_result(item) for item in visual_claims],
|
|
"render_manifest": (facts.get("report") or {}).get("render_manifest") or {},
|
|
"reference_image_paths": self.artifacts.source_image_paths(task_id),
|
|
"instruction": "Review the final CAD renders against the original reference images and the ordered visual claims. Return exactly one visual_claims decision for each supplied visual claim, in the same order. Deterministic results are final. Do not return any runtime identifiers.",
|
|
}, schema=stateless_final_review_schema(len(visual_claims)), feedback=feedback)
|
|
|
|
async def _review_tool(self, task_id: str, reviewer: ModelIdentity, name: str, model_type: type[T], payload: dict[str, Any], *, schema: dict[str, Any] | None = None, feedback: list[dict[str, Any]] | None = None) -> T | WorkflowError:
|
|
if feedback:
|
|
payload = {**payload, "previous_schema_or_state_error": str(feedback[-1].get("content") or "")[:2_000]}
|
|
try:
|
|
tool = self._tool(name, schema or model_type)
|
|
kind = "image_observation" if name == "observe_images" else "candidate" if name == "review_candidate" else "final"
|
|
response = await self.review_gateway.review(kind=kind, payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id)
|
|
except AdapterUnavailable as error:
|
|
error_code = (
|
|
ErrorCode.RENDER_SERVICE_UNAVAILABLE
|
|
if str(error).startswith("RENDER_SERVICE_UNAVAILABLE:")
|
|
else ErrorCode.REVIEW_SERVICE_UNAVAILABLE
|
|
)
|
|
self.repository.record_usage(task_id, {
|
|
"prompt_tokens": 0,
|
|
"completion_tokens": 0,
|
|
"total_tokens": 0,
|
|
"usage_available": False,
|
|
"context_chars": len(json.dumps(payload, ensure_ascii=False)),
|
|
"tool": name,
|
|
"provider_id": reviewer.provider_id,
|
|
"model_id": reviewer.model_id,
|
|
"role": "reviewer",
|
|
"retry_reason": error_code.value.lower(),
|
|
"cache_hit": False,
|
|
})
|
|
return WorkflowError(error_code, str(error)[:1000], retryable=True)
|
|
check = validate_one_tool_call(response["tool_calls"], name)
|
|
if isinstance(check, WorkflowError):
|
|
self._record_rejected_tool_calls(
|
|
task_id,
|
|
actor="reviewer",
|
|
expected_tool=name,
|
|
tool_calls=response["tool_calls"],
|
|
schema=(tool.get("function") or {}).get("parameters"),
|
|
)
|
|
return check
|
|
_tool_name, raw = check
|
|
self._record_tool_audit(
|
|
task_id,
|
|
actor="reviewer",
|
|
tool=name,
|
|
raw_arguments=raw,
|
|
schema=(tool.get("function") or {}).get("parameters"),
|
|
)
|
|
self.repository.record_usage(task_id, {
|
|
**response["usage"],
|
|
"context_chars": len(json.dumps(payload, ensure_ascii=False)),
|
|
"tool": name,
|
|
"provider_id": reviewer.provider_id,
|
|
"model_id": reviewer.model_id,
|
|
"role": "reviewer",
|
|
"raw_arguments_hash": raw_arguments_hash(raw),
|
|
"retry_reason": "",
|
|
"cache_hit": False,
|
|
})
|
|
validated = canonical_validate(raw, model_type)
|
|
if isinstance(validated, WorkflowError):
|
|
return validated
|
|
dynamic_error = canonical_validate_schema(raw, schema) if schema is not None else None
|
|
return dynamic_error or validated # type: ignore[return-value]
|
|
|
|
def _record_tool_audit(
|
|
self,
|
|
task_id: str,
|
|
*,
|
|
actor: str,
|
|
tool: str,
|
|
raw_arguments: str,
|
|
schema: Any,
|
|
returned_tool: str | None = None,
|
|
single_allowed_call: bool = True,
|
|
) -> None:
|
|
"""Persist the raw-output validation and current state-binding facts.
|
|
|
|
Provider parsers are not a trust boundary. This stores a redacted,
|
|
diagnostic copy locally while release reports expose only the hash and
|
|
validation/binding summary.
|
|
"""
|
|
state = self.repository.get_state(task_id)
|
|
schema_error = canonical_validate_schema(raw_arguments, schema) if isinstance(schema, dict) else WorkflowError(
|
|
ErrorCode.RUNTIME_CONTRACT_INVALID,
|
|
"The exposed tool has no JSON Schema.",
|
|
)
|
|
parsed = canonical_json_object(raw_arguments)
|
|
supplied_head = parsed.get("working_head") if isinstance(parsed, dict) else None
|
|
topology = self.artifacts.read_topology(task_id, state.active_revision) if state and state.active_revision else None
|
|
pending = state.pending_action if state else None
|
|
contract_current = True
|
|
if pending is not None:
|
|
try:
|
|
contract_current = self.runtime.operation_contract(pending.atomic_id).get("contract_hash") == pending.contract_hash
|
|
except Exception:
|
|
contract_current = False
|
|
schema_head = self._schema_working_head(schema)
|
|
expected_head = schema_head or (state.working_head if state is not None else "")
|
|
# Candidate reviews are scoped to the action checkpoint that was used
|
|
# to build the candidate. The state has already advanced by the time
|
|
# the review runs, so current-state equality would reject a valid,
|
|
# schema-bound response. The dynamic schema is server-generated and
|
|
# canonical-validated above, making its const the authority here.
|
|
working_head_matches = not isinstance(supplied_head, str) or (bool(expected_head) and supplied_head == expected_head)
|
|
binding_valid = bool(state) and single_allowed_call and schema_error is None and working_head_matches and contract_current
|
|
self.repository.record_tool_audit(task_id, {
|
|
"schema_version": "cad.v3.tool-audit.v2",
|
|
"actor": actor,
|
|
"tool": tool,
|
|
"raw_arguments_hash": raw_arguments_hash(raw_arguments),
|
|
"redacted_arguments": self._redact_tool_arguments(parsed) if isinstance(parsed, dict) else None,
|
|
"canonical_schema_valid": schema_error is None,
|
|
"field_errors": list(schema_error.field_errors) if isinstance(schema_error, WorkflowError) else [],
|
|
"state_binding": {
|
|
"phase": state.phase.value if state else "",
|
|
"working_head": state.working_head if state else "",
|
|
"expected_working_head": expected_head,
|
|
"working_head_binding_source": "schema_const" if schema_head else "current_state",
|
|
"supplied_working_head": str(supplied_head or ""),
|
|
"working_head_matches": working_head_matches,
|
|
"active_revision": state.active_revision if state else "",
|
|
"contract_hash": pending.contract_hash if pending else "",
|
|
"contract_hash_current": contract_current,
|
|
"selector_snapshot_id": str((topology or {}).get("snapshot_id") or ""),
|
|
"binding_valid": binding_valid,
|
|
},
|
|
"returned_tool": returned_tool if returned_tool is not None else tool,
|
|
"single_allowed_call": single_allowed_call,
|
|
})
|
|
|
|
@staticmethod
|
|
def _schema_working_head(schema: Any) -> str | None:
|
|
"""Return a server-bound head from a dynamic top-level tool schema."""
|
|
properties = schema.get("properties") if isinstance(schema, dict) else None
|
|
working_head = properties.get("working_head") if isinstance(properties, dict) else None
|
|
value = working_head.get("const") if isinstance(working_head, dict) else None
|
|
return value if isinstance(value, str) else None
|
|
|
|
def _record_rejected_tool_calls(
|
|
self,
|
|
task_id: str,
|
|
*,
|
|
actor: str,
|
|
expected_tool: str,
|
|
tool_calls: list[dict[str, Any]],
|
|
schema: Any,
|
|
) -> None:
|
|
"""Audit every returned call even when the one-call gate rejects it."""
|
|
if not tool_calls:
|
|
self._record_tool_audit(
|
|
task_id,
|
|
actor=actor,
|
|
tool=expected_tool,
|
|
raw_arguments="",
|
|
schema=schema,
|
|
returned_tool="",
|
|
single_allowed_call=False,
|
|
)
|
|
return
|
|
for call in tool_calls:
|
|
function = call.get("function") if isinstance(call, dict) and isinstance(call.get("function"), dict) else {}
|
|
name = str(function.get("name") or "")
|
|
arguments = function.get("arguments")
|
|
raw = arguments if isinstance(arguments, str) else ""
|
|
self._record_tool_audit(
|
|
task_id,
|
|
actor=actor,
|
|
tool=expected_tool,
|
|
raw_arguments=raw,
|
|
schema=schema,
|
|
returned_tool=name,
|
|
single_allowed_call=False,
|
|
)
|
|
|
|
@staticmethod
|
|
def _redact_tool_arguments(value: Any) -> Any:
|
|
if isinstance(value, list):
|
|
return [WorkflowCoordinator._redact_tool_arguments(item) for item in value]
|
|
if not isinstance(value, dict):
|
|
return value
|
|
sensitive = {"api_key", "authorization", "credential", "credentials", "secret", "token", "password"}
|
|
return {
|
|
str(key): "[REDACTED]" if str(key).casefold() in sensitive else WorkflowCoordinator._redact_tool_arguments(item)
|
|
for key, item in value.items()
|
|
}
|
|
|
|
def _action_tools(self, task_id: str, state: TaskState, observed: set[str] | None = None) -> list[dict[str, Any]]:
|
|
action = state.pending_action
|
|
if action is None:
|
|
return []
|
|
contract = self.runtime.operation_contract(action.atomic_id)
|
|
selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden")
|
|
seen = observed or set()
|
|
topology = self.artifacts.read_topology(task_id, state.active_revision)
|
|
tokens = self.runtime.selector_tokens(topology)
|
|
eligible_tokens = self._selector_tokens_for_contract(contract, tokens)
|
|
if selector_shape == "required" and len(eligible_tokens) > 16 and "topology" not in seen:
|
|
return [self._tool("inspect_topology", StatelessTopologyRequest)]
|
|
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
|
|
description = "Submit exactly one CDSL feature for the pending action."
|
|
if not state.active_revision and action.atomic_id in {"extrude_add_blind", "extrude_add_two_sided"}:
|
|
description += " Root extrusion uses the world XY datum: workplane.origin_mm must be [0, 0, Z], normal [0, 0, 1], and x_dir [1, 0, 0]. Profile coordinates are local to that plane."
|
|
if action.atomic_id.startswith("hole_"):
|
|
description += (
|
|
" Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. "
|
|
"It is not a host-face-local offset; the server converts the world point to the selected face frame."
|
|
)
|
|
if selector_shape == "required":
|
|
description += (
|
|
" The root payload shape is {\"feature\":{\"atomic_id\":\"...\","
|
|
"\"selector_tokens\":[\"opaque topology token\"],\"params\":{...}}}. "
|
|
"feature.selector_tokens is required author input: copy one of the opaque tokens from "
|
|
"inspect_topology; the server resolves it to the host after validation."
|
|
)
|
|
fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}}
|
|
return [fragment]
|
|
|
|
def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
|
if not state.repair_required:
|
|
return []
|
|
if self.actions.rollback_available(task_id, state):
|
|
return [self._tool("rollback_checkpoint", stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))))]
|
|
if self.actions.repair_action_ready(task_id, state):
|
|
return []
|
|
evidence_refs = list(self.actions.diagnostic_evidence_refs(task_id, state))
|
|
if not evidence_refs:
|
|
return []
|
|
return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)]
|
|
|
|
def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
state = self.repository.get_state(task_id)
|
|
if state is None:
|
|
return []
|
|
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
|
|
content = {
|
|
"protocol": "cad.v3.1.markdown-first",
|
|
"source_requirements": self.artifacts.read_source_requirements(task_id),
|
|
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
|
|
"user_clarifications": self._user_clarifications(task_id),
|
|
"instruction": (
|
|
"Write the frozen engineering-expanded requirements Markdown using every required heading. Clearly separate explicit user facts from engineering defaults. "
|
|
"Conventional functional geometry is allowed for underspecified common parts, but never contradict explicit text or the image observation. Do not include runtime identifiers."
|
|
),
|
|
}
|
|
elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET:
|
|
content = {
|
|
"protocol": "cad.v3.1.markdown-first",
|
|
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
|
|
"instruction": "Write # Completion Target with unique - [ ] checklist items. Each item must describe one independently observable final feature or condition. Do not add requirements not present in the frozen requirements document and do not include runtime identifiers.",
|
|
}
|
|
elif state.phase == TaskPhase.COMPILING_REQUIREMENTS:
|
|
content = {
|
|
"protocol": "cad.v3.1.markdown-first",
|
|
"source_requirements": self.artifacts.read_source_requirements(task_id),
|
|
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
|
|
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
|
|
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
|
|
"verifier_registry": self.requirements.registry.expected_one_of_schema(),
|
|
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance.",
|
|
}
|
|
elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN:
|
|
content = {
|
|
"protocol": "cad.v3.1.markdown-first",
|
|
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
|
|
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
|
|
"compiled_contract": self._requirements_contract(task_id, state),
|
|
"instruction": "Write # Modeling Plan with a short ordered list of feature-construction steps. It is a frozen execution guide only: do not add, remove, or reinterpret requirements and do not include runtime identifiers.",
|
|
}
|
|
else:
|
|
contract = self._requirements_contract(task_id, state) or {}
|
|
compact = [{
|
|
"statement": item.get("statement"),
|
|
"acceptance_claims": [
|
|
{
|
|
"claim_kind": claim.get("claim_kind"),
|
|
"expected": claim.get("expected"),
|
|
}
|
|
for claim in item.get("acceptance_claims") or ()
|
|
if isinstance(claim, dict)
|
|
],
|
|
} for item in contract.get("requirements") or () if isinstance(item, dict)]
|
|
action = state.pending_action
|
|
operation = self.runtime.operation_contract(action.atomic_id) if action is not None else None
|
|
selector_shape = str((operation or {}).get("fragment_shape", {}).get("selector_tokens") or "forbidden")
|
|
instruction = "Choose only the next atomically verifiable action or complete when every requirement is proven. The service will not choose CAD operations for you."
|
|
if state.repair_required:
|
|
instruction = "A prior candidate or final review requires repair. Use the current server evidence to record a geometry conclusion, then either choose a new action or, after a rollback conclusion, request an earlier checkpoint. The service will not choose CAD operations for you."
|
|
operation_payload = self._operation_payload(task_id, state) if action is not None else None
|
|
selector_summary = []
|
|
if action is not None and selector_shape == "required":
|
|
tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
|
|
allowed = set(self._selector_tokens_for_contract(operation or {}, tokens))
|
|
selector_summary = [
|
|
{"token": token, "kind": value.get("kind"), "geometry": {key: value.get("geometry", {}).get(key) for key in ("center_mm", "normal", "bbox_mm", "surface_type") if key in value.get("geometry", {})}}
|
|
for token, value in tokens.items() if token in allowed
|
|
][:16]
|
|
instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated."
|
|
content = {"protocol": "cad.v3.1", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), "modeling_plan_markdown": self._read_markdown(task_id, state.modeling_plan_path), "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
|
|
messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
|
|
return [*messages, *feedback[-2:]]
|
|
|
|
def _user_clarifications(self, task_id: str) -> list[dict[str, str]]:
|
|
clarifications: list[dict[str, str]] = []
|
|
for event in self.repository.ledger_events(task_id):
|
|
if event.get("event") != "user_clarification_received":
|
|
continue
|
|
path = str(event.get("clarification_path") or "")
|
|
payload = self.artifacts.read_json(task_id, path) if path else None
|
|
text = str((payload or {}).get("text") or "").strip()
|
|
if text:
|
|
clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text})
|
|
return clarifications
|
|
|
|
def _read_markdown(self, task_id: str, relative_path: str) -> str:
|
|
if not relative_path:
|
|
return ""
|
|
try:
|
|
path = self.artifacts.artifact_path(task_id, relative_path)
|
|
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
except (OSError, ValueError):
|
|
return ""
|
|
|
|
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
|
"""Expose the persisted requirement question when a task is parked.
|
|
|
|
The clarification artifact is the durable source of the single human
|
|
decision needed to continue this task.
|
|
"""
|
|
clarification = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None
|
|
question = str((clarification or {}).get("question") or "").strip()
|
|
questions = [question] if question else []
|
|
if not questions:
|
|
raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question")
|
|
message = f"Requirements need a user decision. {questions[0]}"
|
|
payload: dict[str, Any] = {
|
|
"taskId": task_id,
|
|
"lifecycle": TaskPhase.WAITING_FOR_USER.value.lower(),
|
|
"revisionId": state.active_revision,
|
|
"code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value,
|
|
"message": message,
|
|
"questions": questions,
|
|
"clarificationPath": state.clarification_path,
|
|
"blockerType": "requirements_ambiguity",
|
|
"userActionRequired": True,
|
|
}
|
|
return payload
|
|
|
|
def _requirements_contract(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None:
|
|
if state is None or not state.requirements_contract_path:
|
|
return None
|
|
return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
|
|
|
def _ensure_recovered_completion_result(self, task_id: str, state: TaskState) -> None:
|
|
result_path = self.artifacts.artifact_path(task_id, "completion-result.md")
|
|
if result_path.is_file():
|
|
return
|
|
facts = self.actions._facts(task_id, state.active_revision)
|
|
claim_results = self.actions._evaluate_claims(task_id, facts)
|
|
raw_review = self.artifacts.read_json(task_id, f"reviews/final/{state.active_revision}/final-review.json") or {}
|
|
coverage = {
|
|
str(item.get("claim_id") or ""): item
|
|
for item in raw_review.get("claim_coverage") or ()
|
|
if isinstance(item, dict)
|
|
}
|
|
visual_claims = [
|
|
{
|
|
"status": str(coverage.get(str(item.get("claim_id") or ""), {}).get("status") or "fail"),
|
|
"evidence": "; ".join(str(value) for value in raw_review.get("evidence") or ()) or "Recovered final review decision.",
|
|
}
|
|
for item in claim_results
|
|
if not item.get("deterministic")
|
|
]
|
|
self.requirements.write_completion_result(
|
|
task_id,
|
|
state,
|
|
claim_results=claim_results,
|
|
review={"visual_claims": visual_claims},
|
|
)
|
|
|
|
@staticmethod
|
|
def _public_requirements(contract: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"statement": str(item.get("statement") or ""),
|
|
"assumptions": list(item.get("assumptions") or []),
|
|
"acceptance_claims": [
|
|
{
|
|
"claim_kind": str(claim.get("claim_kind") or ""),
|
|
"expected": claim.get("expected") or {},
|
|
"verification_mode": str(claim.get("verification_mode") or ""),
|
|
}
|
|
for claim in item.get("acceptance_claims") or ()
|
|
if isinstance(claim, dict)
|
|
],
|
|
}
|
|
for item in (contract or {}).get("requirements") or ()
|
|
if isinstance(item, dict)
|
|
]
|
|
|
|
@staticmethod
|
|
def _public_claim_result(item: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
key: value
|
|
for key, value in item.items()
|
|
if key not in {"claim_id", "requirement_id", "evidence_refs"}
|
|
}
|
|
|
|
@classmethod
|
|
def _public_candidate(cls, candidate: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"claim_results": [cls._public_claim_result(item) for item in candidate.get("claim_results") or () if isinstance(item, dict)],
|
|
"operation_verifier_results": [cls._public_claim_result(item) for item in candidate.get("operation_verifier_results") or () if isinstance(item, dict)],
|
|
"health": candidate.get("health") or {},
|
|
"model_summary": candidate.get("model_summary") or {},
|
|
}
|
|
|
|
@staticmethod
|
|
def _public_pending_context(state: TaskState) -> dict[str, Any] | None:
|
|
action = state.pending_action
|
|
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
|
|
|
|
@staticmethod
|
|
def _coordinate_protocol(state: TaskState) -> dict[str, str]:
|
|
protocol = {
|
|
"system": "world_mm_right_handed",
|
|
"sketch_mapping": "workplane.origin_mm is the world position of sketch local (0,0); profile points such as circle.center are sketch-local.",
|
|
"vectors": "normal is positive extrusion direction; x_dir is sketch local +X expressed in world coordinates.",
|
|
"hosted_features": "For existing solids, use the selected max_z/min_z face and its supplied normal; do not infer or hand-copy a world-space offset.",
|
|
}
|
|
if not state.active_revision:
|
|
protocol["root_extrusion"] = "Root extrude_add_blind and extrude_add_two_sided are bound to world XY: origin=[0,0,Z], normal=[0,0,1], x_dir=[1,0,0]. Requirements decide Z only; never place a Z offset in Y."
|
|
return protocol
|
|
|
|
@staticmethod
|
|
def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
return {
|
|
"operation": payload.get("atomic_id"),
|
|
"contract": payload.get("contract"),
|
|
"fragment_schema": payload.get("fragment_schema"),
|
|
}
|
|
|
|
def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
|
projection = self.repository.get_task_projection(task_id) or {}
|
|
return {
|
|
"taskId": task_id,
|
|
"lifecycle": str(projection.get("lifecycle") or state.phase.value.lower()),
|
|
"revisionId": state.active_revision,
|
|
"code": state.last_error.value if state.last_error else "",
|
|
"message": str(projection.get("message") or ("CAD generation completed." if state.phase == TaskPhase.COMPLETED else "CAD generation stopped.")),
|
|
"questions": projection.get("questions") or [],
|
|
"issues": projection.get("issues") or [],
|
|
"blockerType": str(projection.get("blocker_type") or ""),
|
|
"userActionRequired": bool(projection.get("user_action_required")),
|
|
"verificationStatus": str(projection.get("verification_status") or "verified"),
|
|
"verificationWarnings": projection.get("verification_warnings") or [],
|
|
}
|
|
|
|
def _operation_payload(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
|
action = state.pending_action
|
|
assert action is not None
|
|
contract = self.runtime.operation_contract(action.atomic_id)
|
|
topology = self.artifacts.read_topology(task_id, state.active_revision)
|
|
tokens = self.runtime.selector_tokens(topology)
|
|
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
|
|
return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}
|
|
|
|
def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
|
constraints: list[dict[str, Any]] = []
|
|
for event in reversed(self.repository.ledger_events(task_id)):
|
|
if event.get("checkpoint_revision") != state.active_revision:
|
|
continue
|
|
if not event.get("normalized_error_code"):
|
|
continue
|
|
constraints.append({
|
|
"atomic_id": str(event.get("atomic_id") or ""),
|
|
"normalized_error_code": str(event.get("normalized_error_code") or ""),
|
|
"fragment_hash": str(event.get("fragment_hash") or ""),
|
|
"prohibited_exact_fingerprint": str(event.get("failure_exact_fingerprint") or ""),
|
|
"attempt": int(event.get("attempt") or 1),
|
|
"message": str(event.get("message") or event.get("reason") or "")[:360],
|
|
})
|
|
if len(constraints) == 4:
|
|
break
|
|
return constraints
|
|
|
|
@staticmethod
|
|
def _selector_tokens_for_contract(contract: dict[str, Any], tokens: dict[str, dict[str, Any]]) -> list[str]:
|
|
"""Narrow dynamic selector enums to the current contract's kind."""
|
|
if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") != "required":
|
|
return []
|
|
policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {}
|
|
kind = str(policy.get("token_kind") or "")
|
|
return [token for token, value in tokens.items() if isinstance(value, dict) and value.get("kind") == kind]
|
|
|
|
def _topology_payload(self, task_id: str, state: TaskState, kind: str | None, limit: int) -> dict[str, Any]:
|
|
tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
|
|
values = [{"token": token, "kind": item["kind"], "geometry": {key: item["geometry"].get(key) for key in ("center_mm", "normal", "plane_normal", "bbox_mm", "radius_mm", "surface_type") if key in item["geometry"]}} for token, item in tokens.items() if not kind or item["kind"] == kind]
|
|
return {
|
|
"working_head": state.working_head,
|
|
"coordinate_system": "world_mm",
|
|
"tokens": values[:limit],
|
|
}
|
|
|
|
def _can_complete(self, task_id: str, state: TaskState) -> bool:
|
|
if not state.active_revision or state.repair_required:
|
|
return False
|
|
results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision))
|
|
return bool(results) and all(item.get("status") == "pass" for item in results if item.get("deterministic"))
|
|
|
|
def _transport_or_failure(self, task_id: str, state: TaskState, error: WorkflowError, author: ModelIdentity, attempted: set[str]) -> tuple[ModelIdentity, tuple[str, dict[str, Any]] | None]:
|
|
if error.code != ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE:
|
|
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
|
|
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": error.message}])
|
|
return author, ("task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": error.message})
|
|
attempted.add(author.provider_id)
|
|
# The adapter already performs bounded exponential retries for the
|
|
# active provider. The workflow permits one, and only one, provider
|
|
# failover before parking the durable task for explicit recovery.
|
|
fallback = next((candidate for candidate in self.config.author_fallbacks if candidate.provider_id not in attempted), None) if len(attempted) == 1 else None
|
|
if fallback is not None:
|
|
self.repository.append_outbox(task_id, {"event": "author_provider_failover", "from_provider": author.provider_id, "to_provider": fallback.provider_id})
|
|
return fallback, None
|
|
waiting = transition(state, "waiting_retry", error=ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE)
|
|
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message}])
|
|
return author, ("task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message})
|
|
|
|
def _service_failure(self, task_id: str, state: TaskState, error: WorkflowError) -> tuple[str, dict[str, Any]]:
|
|
waiting = transition(state, "waiting_retry", error=error.code)
|
|
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": error.code.value, "message": error.message}])
|
|
return "task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": error.code.value, "message": error.message}
|
|
|
|
def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None:
|
|
"""Bound no-side-effect model rejections; park real service failures."""
|
|
if error.code == ErrorCode.NO_PROGRESS_LIMIT:
|
|
current = self.repository.get_state(task_id) or state
|
|
terminal = self._best_effort_terminal(
|
|
task_id,
|
|
current,
|
|
ErrorCode.NO_PROGRESS_LIMIT,
|
|
"Further attempts repeated an already failed CAD path; publishing the last executable checkpoint.",
|
|
)
|
|
if terminal:
|
|
return terminal
|
|
feedback[:] = [self._feedback(error)]
|
|
return None
|
|
if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID:
|
|
# Registry integrity is a deployment defect. Retrying a model with
|
|
# the same broken contract cannot repair it and must never consume
|
|
# the author-format budget.
|
|
terminal = self._best_effort_terminal(task_id, state, error.code, error.message)
|
|
if terminal:
|
|
return terminal
|
|
failed = transition(state, "failed", error=error.code)
|
|
self.repository.compare_and_swap(failed, events=[{
|
|
"event": "runtime_contract_invalid",
|
|
"tool": name,
|
|
"message": error.message,
|
|
}])
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "failed",
|
|
"code": error.code.value,
|
|
"message": error.message,
|
|
}
|
|
if error.code == ErrorCode.REQUIREMENTS_SPEC_INVALID:
|
|
# A frozen verifier contract is service-owned input at this stage.
|
|
# CAD retries cannot repair it, so preserve the terminal diagnosis
|
|
# instead of parking the task or blaming the author fragment.
|
|
current = self.repository.get_state(task_id)
|
|
if current is not None and current.phase != TaskPhase.FAILED:
|
|
failed = transition(current, "failed", error=error.code)
|
|
self.repository.compare_and_swap(failed, events=[{
|
|
"event": "requirements_contract_execution_failed",
|
|
"tool": name,
|
|
"message": error.message,
|
|
}])
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "failed",
|
|
"code": error.code.value,
|
|
"message": error.message,
|
|
"blockerType": "requirements_contract_invalid",
|
|
"userActionRequired": False,
|
|
"issues": [str(error.details.get("diagnostic") or error.message)],
|
|
}
|
|
if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED:
|
|
# A schema-valid fragment can still be impossible on the current
|
|
# geometry. This is not an author-format failure: preserve the
|
|
# accepted checkpoint, discard only the pending action and let
|
|
# the author make a fresh, evidence-backed choice.
|
|
if state.phase == TaskPhase.ACTION_PENDING:
|
|
action = state.pending_action
|
|
atomic_id = str(error.details.get("atomic_id") or (action.atomic_id if action else ""))
|
|
checkpoint_revision = str(error.details.get("active_revision") or state.active_revision)
|
|
normalized_code = str(error.details.get("normalized_error_code") or error.code.value)
|
|
failure_class_fingerprint = sha256(
|
|
f"{checkpoint_revision}|{atomic_id}|{normalized_code}".encode("utf-8")
|
|
).hexdigest()
|
|
prior = [
|
|
event for event in self.repository.ledger_events(task_id)
|
|
if event.get("failure_class_fingerprint") == failure_class_fingerprint
|
|
]
|
|
if len(prior) >= 2:
|
|
next_state = transition(state, "runtime_precondition_rejected", pending_action=None, error=ErrorCode.NO_PROGRESS_LIMIT)
|
|
self.repository.compare_and_swap(next_state, events=[{
|
|
"event": "no_progress_limit",
|
|
"tool": name,
|
|
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
|
"message": error.message,
|
|
"checkpoint_revision": checkpoint_revision,
|
|
"atomic_id": atomic_id,
|
|
"normalized_error_code": normalized_code,
|
|
"failure_class_fingerprint": failure_class_fingerprint,
|
|
}])
|
|
terminal = self._best_effort_terminal(
|
|
task_id,
|
|
next_state,
|
|
ErrorCode.NO_PROGRESS_LIMIT,
|
|
"The same operation failure made no progress; publishing the last executable checkpoint.",
|
|
)
|
|
if terminal:
|
|
return terminal
|
|
feedback[:] = [self._feedback(error)]
|
|
return None
|
|
next_state = transition(
|
|
state,
|
|
"runtime_precondition_rejected",
|
|
pending_action=None,
|
|
error=ErrorCode.RUNTIME_PRECONDITION_FAILED,
|
|
)
|
|
self.repository.compare_and_swap(next_state, events=[{
|
|
"event": "runtime_precondition_rejected",
|
|
"tool": name,
|
|
"code": error.code.value,
|
|
"message": error.message,
|
|
"checkpoint_revision": checkpoint_revision,
|
|
"atomic_id": atomic_id,
|
|
"fragment_hash": error.details.get("fragment_hash"),
|
|
"failure_exact_fingerprint": error.details.get("failure_exact_fingerprint"),
|
|
"normalized_error_code": normalized_code,
|
|
"failure_class_fingerprint": failure_class_fingerprint,
|
|
"attempt": len(prior) + 1,
|
|
}])
|
|
feedback_error = error
|
|
if prior:
|
|
feedback_error = WorkflowError(
|
|
error.code,
|
|
error.message + " A different fragment or operation path is required; do not repeat the proven failure class.",
|
|
field_errors=error.field_errors,
|
|
details={**error.details, "prohibited_failure_class": failure_class_fingerprint},
|
|
)
|
|
feedback[:] = [self._feedback(feedback_error)]
|
|
return None
|
|
# A precondition result outside fragment submission is an invalid
|
|
# workflow implementation state, not a provider/service outage.
|
|
terminal = self._best_effort_terminal(task_id, state, error.code, error.message)
|
|
if terminal:
|
|
return terminal
|
|
failed = transition(state, "failed", error=error.code)
|
|
self.repository.compare_and_swap(failed, events=[{
|
|
"event": "runtime_precondition_rejected",
|
|
"tool": name,
|
|
"code": error.code.value,
|
|
"message": error.message,
|
|
}])
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "failed",
|
|
"code": error.code.value,
|
|
"message": error.message,
|
|
}
|
|
if error.code in {
|
|
ErrorCode.CANDIDATE_BUILD_FAILED,
|
|
ErrorCode.CLAIM_VERIFICATION_FAILED,
|
|
ErrorCode.CANDIDATE_REVIEW_REJECTED,
|
|
}:
|
|
# The handler already preserved the last checkpoint and recorded
|
|
# the field/runtime diagnostic. Continue with that evidence; a
|
|
# planning miss is not an infrastructure terminal condition.
|
|
feedback[:] = [self._feedback(error)]
|
|
return None
|
|
model_rejection_codes = {
|
|
ErrorCode.AUTHOR_FORMAT_INVALID,
|
|
ErrorCode.AUTHOR_DECISION_REJECTED,
|
|
ErrorCode.STALE_WORKING_HEAD,
|
|
}
|
|
if error.code in model_rejection_codes:
|
|
return self._format_failure(task_id, state, name, error, counters, feedback, actor=actor)
|
|
return self._service_failure(task_id, state, error)
|
|
|
|
def _format_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None:
|
|
counters[name] = counters.get(name, 0) + 1
|
|
feedback[:] = [self._feedback(error)]
|
|
if counters[name] < self.config.format_error_limit:
|
|
return None
|
|
terminal = self._best_effort_terminal(
|
|
task_id,
|
|
state,
|
|
ErrorCode.FAILED_AUTHOR_FORMAT,
|
|
f"{actor.capitalize()} repeatedly failed the canonical schema; publishing the last executable checkpoint.",
|
|
)
|
|
if terminal:
|
|
return terminal
|
|
failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT)
|
|
self.repository.compare_and_swap(failed, events=[{"event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors)}])
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "failed",
|
|
"code": ErrorCode.FAILED_AUTHOR_FORMAT.value,
|
|
"message": f"{actor.capitalize()} repeatedly failed the same canonical schema or state contract.",
|
|
"tool": name,
|
|
"field_errors": list(error.field_errors),
|
|
}
|
|
|
|
def _requirements_format_failure(
|
|
self,
|
|
task_id: str,
|
|
state: TaskState,
|
|
error: WorkflowError,
|
|
counters: dict[str, int],
|
|
feedback: list[dict[str, Any]],
|
|
*,
|
|
tool: str = "compile_requirements_spec",
|
|
) -> tuple[str, dict[str, Any]] | None:
|
|
key = "requirements_spec"
|
|
counters[key] = counters.get(key, 0) + 1
|
|
feedback[:] = [self._feedback(error)]
|
|
if counters[key] < 2:
|
|
return None
|
|
failed = transition(state, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID)
|
|
self.repository.compare_and_swap(failed, events=[{
|
|
"event": "requirements_spec_invalid",
|
|
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
|
|
"message": error.message,
|
|
"field_errors": list(error.field_errors),
|
|
}])
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "failed",
|
|
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
|
|
"message": "Requirements specification remained unreadable after one field-level correction.",
|
|
"tool": tool,
|
|
"field_errors": list(error.field_errors),
|
|
"userActionRequired": False,
|
|
}
|
|
|
|
def _requirements_rejection(
|
|
self,
|
|
task_id: str,
|
|
state: TaskState,
|
|
error: WorkflowError,
|
|
counters: dict[str, int],
|
|
feedback: list[dict[str, Any]],
|
|
*,
|
|
tool: str = "compile_requirements_spec",
|
|
) -> tuple[str, dict[str, Any]] | None:
|
|
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
|
|
return self._service_failure(task_id, state, error)
|
|
return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool)
|
|
|
|
@staticmethod
|
|
def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]:
|
|
parameters = model if isinstance(model, dict) else model.model_json_schema()
|
|
return {"type": "function", "function": {"name": name, "description": name.replace("_", " "), "parameters": parameters}}
|
|
|
|
@staticmethod
|
|
def _result_payload(result: Accepted | Rejected | Waiting) -> dict[str, Any]:
|
|
if isinstance(result, Accepted):
|
|
return result.payload
|
|
return result.error.payload()
|
|
|
|
@staticmethod
|
|
def _feedback(error: WorkflowError) -> dict[str, Any]:
|
|
return {"role": "user", "content": json.dumps({"schema_or_state_error": error.payload(), "instruction": "Correct only the reported fields and return one allowed tool call."}, ensure_ascii=False)}
|
|
|
|
@staticmethod
|
|
def _pending_context(state: TaskState) -> dict[str, Any] | None:
|
|
action = state.pending_action
|
|
return {"action_id": action.action_id, "working_head": action.working_head, "intent": action.intent, "requirement_ids": list(action.requirement_ids), "atomic_id": action.atomic_id, "expected_change": action.expected_change, "contract_hash": action.contract_hash} if action else None
|
|
|
|
@staticmethod
|
|
def _tool_feedback(content: str) -> dict[str, Any] | None:
|
|
try:
|
|
value = json.loads(content)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return value if isinstance(value, dict) else None
|
|
|
|
@staticmethod
|
|
def _event(task_id: str, tool: str, result: dict[str, Any], status: str, usage: dict[str, Any]) -> dict[str, Any]:
|
|
return {"taskId": task_id, "tool": tool, "status": status, "result": result, "usage": usage}
|
|
|
|
@staticmethod
|
|
def _invocation_id(task_id: str) -> str:
|
|
return f"{task_id}_inv_{secrets.token_hex(8)}"
|