1467 lines
90 KiB
Python
1467 lines
90 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, EmptyCommand, FinalReview, GeometryConclusion, NextAction,
|
|
OperationContractRequest, RequirementsDraftBatch, RequirementsPatchBatch,
|
|
RequirementsReview, RollbackCheckpoint, TopologyRequest,
|
|
candidate_review_schema, canonical_json_object, canonical_validate, canonical_validate_schema,
|
|
final_review_schema, geometry_conclusion_schema, next_action_schema,
|
|
operation_contract_request_schema, rollback_checkpoint_schema, topology_request_schema,
|
|
raw_arguments_hash, validate_one_tool_call,
|
|
)
|
|
from app.cad_agent.application.requirements_review 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, reject_stale_head, 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
|
|
requirements_review_limit: int = 3
|
|
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,
|
|
) -> 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)
|
|
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
|
|
review = self._requirements_review(task_id, state)
|
|
is_requirements_pause = isinstance(review, dict) and (
|
|
review.get("decision") == "waiting_for_user"
|
|
and any(
|
|
item.get("finding_type") == "ambiguous_conflict" and item.get("question")
|
|
for item in review.get("findings") or () if isinstance(item, dict)
|
|
)
|
|
)
|
|
if not is_requirements_pause:
|
|
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")
|
|
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.requirements_contract_path and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
|
try:
|
|
self.requirements.ensure_rendered_contract_views(task_id, state)
|
|
except Exception as error:
|
|
yield self._service_failure(
|
|
task_id,
|
|
state,
|
|
WorkflowError(ErrorCode.STORAGE_FAILURE, str(error)[:1000], retryable=True),
|
|
)
|
|
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:
|
|
draft_schema = self.requirements.draft_schema(task_id)
|
|
patch_schema = self.requirements.patch_schema(task_id)
|
|
tools = self._requirements_author_tools(task_id, draft_schema, patch_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
|
|
model = RequirementsDraftBatch if name == "submit_requirements_draft_batch" else RequirementsPatchBatch if name == "patch_requirements_draft" else EmptyCommand
|
|
validation = canonical_validate(raw, model)
|
|
dynamic_error = canonical_validate_schema(raw, draft_schema if name == "submit_requirements_draft_batch" else patch_schema) if not isinstance(validation, WorkflowError) and name != "finalize_requirements_draft" 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
|
|
invocation_id = self._invocation_id(task_id)
|
|
command = self.requirements.submit_draft(task_id, validation, invocation_id=invocation_id) if name == "submit_requirements_draft_batch" else self.requirements.patch_draft(task_id, validation, invocation_id=invocation_id) if name == "patch_requirements_draft" else self.requirements.finalize_draft(task_id, invocation_id=invocation_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 state.phase == TaskPhase.REVIEWING_REQUIREMENTS:
|
|
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
call_budget.record_attempt("reviewer")
|
|
command = await self._review_requirements(task_id, reviewer, feedback)
|
|
if isinstance(command, WorkflowError):
|
|
if command.code == ErrorCode.AUTHOR_FORMAT_INVALID:
|
|
terminal = self._format_failure(task_id, state, "review_requirements", command, format_errors, feedback, actor="reviewer")
|
|
yield "requirements_review", {"taskId": task_id, "status": "error", "result": command.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
terminal = self._service_failure(task_id, state, command)
|
|
yield terminal
|
|
return
|
|
review_result = self.requirements.record_review(task_id, command, invocation_id=self._invocation_id(task_id))
|
|
if isinstance(review_result, Rejected):
|
|
terminal = self._model_rejection_or_service_failure(task_id, state, "review_requirements", review_result.error, format_errors, feedback, actor="reviewer")
|
|
yield "requirements_review", {"taskId": task_id, "status": "error", "result": review_result.error.payload()}
|
|
if terminal:
|
|
yield terminal
|
|
return
|
|
continue
|
|
yield "requirements_review", {"taskId": task_id, "status": "success", "result": self._result_payload(review_result)}
|
|
if isinstance(review_result, Accepted) and review_result.payload.get("phase") == TaskPhase.DRAFTING_REQUIREMENTS.value:
|
|
draft_state = self.repository.get_state(task_id)
|
|
revision = int(self._requirements_draft(draft_state).get("revision") or 0)
|
|
# The initial draft is reviewed before any revision.
|
|
# ``requirements_review_limit`` bounds subsequent
|
|
# author corrections, so permit exactly that many
|
|
# patches and park only after they are exhausted.
|
|
if revision > self.config.requirements_review_limit:
|
|
if draft_state is not None:
|
|
failed = transition(draft_state, "failed", error=ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED)
|
|
review = self._requirements_review(task_id, draft_state) or {}
|
|
findings = [
|
|
item for item in review.get("findings") or ()
|
|
if isinstance(item, dict)
|
|
]
|
|
self.repository.compare_and_swap(failed, events=[{
|
|
"event": "requirements_review_limit_reached",
|
|
"code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value,
|
|
"draft_revision": revision,
|
|
"review_path": draft_state.requirements_review_path,
|
|
"findings": findings,
|
|
}])
|
|
yield "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "failed",
|
|
"code": ErrorCode.REQUIREMENTS_REVIEW_NOT_CONVERGED.value,
|
|
"message": "Requirements review did not converge within the configured author-revision limit.",
|
|
"issues": [str(item.get("description") or "") for item in findings if item.get("description")],
|
|
"blockerType": "requirements_review_not_converged",
|
|
"userActionRequired": False,
|
|
}
|
|
return
|
|
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 = next_action_schema(
|
|
state.working_head,
|
|
requirement_ids,
|
|
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":
|
|
diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state)))
|
|
validation = canonical_validate(raw, GeometryConclusion)
|
|
dynamic_error = canonical_validate_schema(raw, diagnostic_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
|
|
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
|
|
elif name == "rollback_checkpoint":
|
|
rollback_schema = rollback_checkpoint_schema(state.working_head, list(self.actions.checkpoint_tokens(task_id, state)))
|
|
validation = canonical_validate(raw, RollbackCheckpoint)
|
|
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
|
|
command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id))
|
|
else:
|
|
validation = canonical_validate(raw, NextAction)
|
|
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
|
|
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":
|
|
diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state)))
|
|
validation = canonical_validate(raw, GeometryConclusion)
|
|
dynamic_error = canonical_validate_schema(raw, diagnostic_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
|
|
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, TopologyRequest)
|
|
dynamic_error = canonical_validate_schema(raw, topology_request_schema(state.working_head)) 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
|
|
stale = reject_stale_head(state, validation.working_head)
|
|
payload = stale.payload() if stale else self._topology_payload(task_id, state, validation.kind, validation.limit)
|
|
if stale is None:
|
|
observed.add("topology")
|
|
yield "tool_call", self._event(task_id, name, payload, "error" if stale else "success", usage)
|
|
feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:]
|
|
continue
|
|
if name == "get_cdsl_operation_contract":
|
|
validation = canonical_validate(raw, OperationContractRequest)
|
|
action = state.pending_action
|
|
dynamic_error = canonical_validate_schema(raw, operation_contract_request_schema(state.working_head, action.atomic_id)) if not isinstance(validation, WorkflowError) and action is not None 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
|
|
action = state.pending_action
|
|
stale = reject_stale_head(state, validation.working_head)
|
|
if stale:
|
|
payload = stale.payload()
|
|
elif action is None or validation.atomic_id != action.atomic_id:
|
|
payload = WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Only the pending action's operation contract is available.").payload()
|
|
else:
|
|
payload = self._operation_payload(task_id, state)
|
|
observed.add("contract")
|
|
yield "tool_call", self._event(task_id, name, payload, "error" if "code" in payload else "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
|
|
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:
|
|
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
|
|
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
|
|
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
|
continue
|
|
state = self.repository.get_state(task_id)
|
|
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)
|
|
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
|
|
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 _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_requirements(self, task_id: str, reviewer: ModelIdentity, feedback: list[dict[str, Any]] | None = None) -> RequirementsReview | WorkflowError:
|
|
state = self.repository.get_state(task_id)
|
|
evaluation_context = self.requirements.evaluation_review_context(task_id)
|
|
instruction = "Return only structured findings; the service derives coverage and the overall decision. Omit a draft item when it has no finding. Bind every finding to one current draft_id and only source_ids cited by that draft. Use missing_source_semantics for omitted source meaning, claim_mismatch for an incorrect deterministic claim, verification_gap only when a scoped visual claim covers a property unavailable to deterministic verifiers, derivable_conflict only with a registered normalization payload, and ambiguous_conflict only when multiple reasonable interpretations remain; ambiguous conflicts require one precise answerable question. For a full-circle equally spaced pattern, 360/count is the only registered automatic rule. Every independently measurable number, count, dimension, relationship, orientation, material/unit constraint, and single-body requirement must have executable or scoped visual coverage. Do not demand an invented deterministic claim for a verifier gap and do not invent requirements absent from the sources."
|
|
if evaluation_context is not None:
|
|
instruction += " The evaluation-only known_validation_capability_gaps are authoritative facts explicitly unavailable to the current deterministic verifier registry, not author errors. Do not request an invented executable claim for one of those gaps. When an otherwise covered draft item has a scoped visual acceptance claim for a declared gap, emit verification_gap once; the service will freeze it with an explicit risk and require final independent visual review."
|
|
return await self._review_tool(task_id, reviewer, "review_requirements", RequirementsReview, {
|
|
"source_index": self.artifacts.read_source_index(task_id), "draft": self._requirements_draft(state), "user_clarifications": self._user_clarifications(task_id),
|
|
"evaluation_context": evaluation_context,
|
|
"instruction": instruction,
|
|
}, schema=self.requirements.review_schema(task_id), feedback=feedback)
|
|
|
|
async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> CandidateReview | 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)
|
|
claim_ids = [str(item.get("claim_id") or "") for item in candidate.get("claim_results") or () if isinstance(item, dict) and item.get("claim_id")]
|
|
return await self._review_tool(task_id, reviewer, "review_candidate", CandidateReview, {
|
|
# Candidate review is checkpoint-scoped. The frozen contract is
|
|
# enough for this decision; source text is retained only for the
|
|
# final review where it guards against a frozen-contract omission.
|
|
"requirements_contract": self._requirements_contract(task_id, state),
|
|
"candidate_id": state.candidate_id, "working_head": action.working_head,
|
|
"action": {"action_id": action.action_id, "intent": action.intent, "expected_change": action.expected_change, "requirement_ids": list(action.requirement_ids)},
|
|
"candidate": candidate, "render_manifest": candidate.get("render_manifest") or {},
|
|
"instruction": "Independently review only this checkpoint against the supplied action, not as the finished model. Return every provided claim ID exactly once. A deterministic claim with status pending is explicitly deferred to a later action: cover it as pending and still return verdict accept unless this candidate contradicts the current action, violates a global invariant, or the renders show this action itself is wrong. In particular, when the action establishes a flange base, the absence of a later through bore is pending and MUST NOT cause rejection. Deterministic results are evidence and cannot be overridden.",
|
|
}, schema=candidate_review_schema(state.candidate_id, action.working_head, claim_ids), feedback=feedback)
|
|
|
|
async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> FinalReview | WorkflowError:
|
|
facts = self.actions._facts(task_id, state.active_revision)
|
|
results = self.actions._evaluate_claims(task_id, facts)
|
|
return await self._review_tool(task_id, reviewer, "review_final", FinalReview, {
|
|
"source_requirements": self.artifacts.read_source_requirements(task_id), "requirements_contract": self._requirements_contract(task_id, state),
|
|
"revision_id": state.active_revision, "working_head": state.working_head, "claim_results": results, "render_manifest": (facts.get("report") or {}).get("render_manifest") or {},
|
|
"instruction": "Independently review final model evidence. Return every claim ID exactly once. Deterministic results are final gates and cannot be overridden.",
|
|
}, schema=final_review_schema(state.working_head, [str(item.get("claim_id") or "") for item in results if item.get("claim_id")]), 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)
|
|
response = await self.review_gateway.review(kind="requirements" if name == "review_requirements" else "candidate" if name == "review_candidate" else "final", 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", topology_request_schema(state.working_head))]
|
|
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 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))}}
|
|
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", rollback_checkpoint_schema(state.working_head, 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", geometry_conclusion_schema(state.working_head, evidence_refs))]
|
|
|
|
def _requirements_author_tools(self, task_id: str, draft_schema: dict[str, Any], patch_schema: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Expose exactly one requirement command from persisted draft facts."""
|
|
state = self.repository.get_state(task_id)
|
|
draft = self._requirements_draft(state)
|
|
review = self._requirements_review(task_id, state)
|
|
if isinstance(review, dict) and review.get("decision") == "revise":
|
|
return [self._tool("patch_requirements_draft", patch_schema)]
|
|
source_ids = set(self.artifacts.read_source_index(task_id))
|
|
covered = {
|
|
str(source_id)
|
|
for item in draft.get("items") or ()
|
|
if isinstance(item, dict)
|
|
for source_id in item.get("source_ids") or ()
|
|
}
|
|
if draft.get("items") and source_ids.issubset(covered):
|
|
return [self._tool("finalize_requirements_draft", EmptyCommand)]
|
|
return [self._tool("submit_requirements_draft_batch", draft_schema)]
|
|
|
|
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:
|
|
draft = self._requirements_draft(state)
|
|
review = self._requirements_review(task_id, state)
|
|
if isinstance(review, dict) and review.get("decision") == "revise":
|
|
current_ids = [str(item.get("draft_id") or "") for item in draft.get("items") or () if isinstance(item, dict) and item.get("draft_id")]
|
|
example_id = current_ids[0] if current_ids else "draft_001"
|
|
instruction = (
|
|
"The independent reviewer requested revisions. Call patch_requirements_draft with exactly one outer patches array. "
|
|
f"Each patch nests target_draft_id (for example {example_id!r}) inside patches[], alongside op. "
|
|
"For op=replace, item is the complete replacement RequirementInput and MUST NOT include draft_id; draft_id is server-owned. "
|
|
"Do not put target_draft_id or item at the top level. The root object must have this exact shape: "
|
|
f'{{"patches":[{{"target_draft_id":"{example_id}","op":"replace","item":{{...}}}}]}}. '
|
|
"Then call finalize_requirements_draft for another review. Do not write Markdown."
|
|
)
|
|
evaluation_context = self.requirements.evaluation_review_context(task_id)
|
|
if evaluation_context is not None:
|
|
instruction += " For a declared evaluation-only validation capability gap, an existing scoped visual claim is valid coverage. Preserve it and do not add a made-up deterministic substitute solely to address that gap."
|
|
else:
|
|
covered = {str(source_id) for item in draft.get("items") or () if isinstance(item, dict) for source_id in item.get("source_ids") or ()}
|
|
source_ids = set(self.artifacts.read_source_index(task_id))
|
|
instruction = "Every source is represented in the draft. Call finalize_requirements_draft now; do not write Markdown." if draft.get("items") and source_ids.issubset(covered) else "Create at most 8 structured requirement items that cover every source. Do not write Markdown."
|
|
content = {"protocol": "cad.v3", "phase": state.phase.value, "source_index": self.artifacts.read_source_index(task_id), "source_requirements": self.artifacts.read_source_requirements(task_id), "user_clarifications": self._user_clarifications(task_id), "draft": draft, "review": review, "evaluation_context": self.requirements.evaluation_review_context(task_id), "instruction": instruction}
|
|
else:
|
|
contract = self._requirements_contract(task_id, state) or {}
|
|
compact = [{
|
|
"requirement_id": item.get("requirement_id"),
|
|
"statement": item.get("statement"),
|
|
"acceptance_claims": [
|
|
{
|
|
"claim_id": claim.get("claim_id"),
|
|
"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", "phase": state.phase.value, "working_head": state.working_head, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "applied_normalizations": contract.get("applied_normalizations") or [], "claim_coverage": self.actions.claim_summary(task_id, state), "model_summary": self.actions.model_summary(task_id, state), "active_revision": state.active_revision, "pending_action": self._pending_context(state), "operation_contract": 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), "recent_ledger": (self.repository.get_task_projection(task_id) or {}).get("action_ledger_summary", [])[-4:], "diagnostic_evidence_refs": list(self.actions.diagnostic_evidence_refs(task_id, state)) if state.repair_required else [], "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 _requirements_draft(self, state: TaskState | None) -> dict[str, Any]:
|
|
if state is None:
|
|
return {"schema_version": "cad.requirements-draft.v1", "revision": 0, "items": []}
|
|
return self.artifacts.read_requirements_draft(state.task_id, state.requirements_draft_path)
|
|
|
|
def _requirements_review(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None:
|
|
return self.artifacts.read_requirements_review(task_id, state.requirements_review_path if state is not None else "")
|
|
|
|
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 waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
|
"""Expose the persisted requirement question when a task is parked.
|
|
|
|
The review artifact is the durable source of a human decision. The
|
|
terminal event deliberately carries only its explicit questions, not
|
|
the full reviewer report, so it remains useful to both SSE clients and
|
|
conversation history without leaking unrelated review detail.
|
|
"""
|
|
review = self._requirements_review(task_id, state) or {}
|
|
questions: list[str] = []
|
|
unresolved: list[dict[str, str]] = []
|
|
for finding in review.get("findings") or ():
|
|
if not isinstance(finding, dict):
|
|
continue
|
|
question = str(finding.get("question") or "").strip()
|
|
finding_type = str(finding.get("finding_type") or "")
|
|
if finding_type == "ambiguous_conflict" and question and question not in questions:
|
|
questions.append(question)
|
|
if finding_type == "ambiguous_conflict":
|
|
unresolved.append({
|
|
"draftId": str(finding.get("draft_id") or ""),
|
|
"reasonCode": finding_type,
|
|
"question": question,
|
|
})
|
|
issues = [
|
|
str(finding.get("description") or "").strip()
|
|
for finding in review.get("findings") or ()
|
|
if isinstance(finding, dict) and str(finding.get("description") or "").strip()
|
|
]
|
|
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,
|
|
"reviewPath": state.requirements_review_path,
|
|
"blockerType": "requirements_ambiguity",
|
|
"userActionRequired": True,
|
|
}
|
|
if issues:
|
|
payload["issues"] = issues
|
|
if unresolved:
|
|
payload["unresolved"] = unresolved
|
|
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 _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 [],
|
|
"appliedNormalizations": projection.get("applied_normalizations") 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))}
|
|
|
|
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.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.
|
|
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.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:
|
|
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
|
|
self.repository.compare_and_swap(failed, 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,
|
|
}])
|
|
return "task_terminal", {
|
|
"taskId": task_id,
|
|
"lifecycle": "failed",
|
|
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
|
"message": "The same operation failure class made no progress after three attempts; the last checkpoint was preserved.",
|
|
"blockerType": "no_progress_limit",
|
|
"userActionRequired": False,
|
|
"revisionId": state.active_revision,
|
|
}
|
|
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.
|
|
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,
|
|
}
|
|
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
|
|
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),
|
|
}
|
|
|
|
@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)}"
|