FlyingNunchucks's picture
Sync from GitHub via hub-sync
152be26 verified
Raw History Blame Contribute Delete
6.58 kB
"""Structured domain models for the Agentic Workflow System.
These schemas define the valid vocabulary for workflow definitions, runtime state,
events, retries, routing, and human review. They intentionally do not perform
cross-node DAG validation; that belongs to the workflow validator layer.
"""
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
def utc_now() -> datetime:
"""Return a timezone-aware UTC timestamp."""
return datetime.now(timezone.utc)
class StrictModel(BaseModel):
"""Base model that rejects unknown fields and strips string whitespace."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class WorkflowStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
WAITING_FOR_HUMAN = "WAITING_FOR_HUMAN"
RETRY_SCHEDULED = "RETRY_SCHEDULED"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
REJECTED = "REJECTED"
class NodeStatus(str, Enum):
PENDING = "PENDING"
READY = "READY"
RUNNING = "RUNNING"
WAITING_FOR_HUMAN = "WAITING_FOR_HUMAN"
RETRY_SCHEDULED = "RETRY_SCHEDULED"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
SKIPPED = "SKIPPED"
class NodeType(str, Enum):
TASK = "TASK"
DECISION = "DECISION"
HUMAN_GATE = "HUMAN_GATE"
END = "END"
class EventType(str, Enum):
WORKFLOW_STARTED = "WORKFLOW_STARTED"
NODE_STARTED = "NODE_STARTED"
NODE_COMPLETED = "NODE_COMPLETED"
NODE_FAILED = "NODE_FAILED"
NODE_SKIPPED = "NODE_SKIPPED"
DECISION_ROUTED = "DECISION_ROUTED"
RETRY_SCHEDULED = "RETRY_SCHEDULED"
HUMAN_REVIEW_REQUESTED = "HUMAN_REVIEW_REQUESTED"
HUMAN_APPROVED = "HUMAN_APPROVED"
HUMAN_REJECTED = "HUMAN_REJECTED"
WORKFLOW_RESUMED = "WORKFLOW_RESUMED"
WORKFLOW_COMPLETED = "WORKFLOW_COMPLETED"
WORKFLOW_FAILED = "WORKFLOW_FAILED"
class HumanDecision(str, Enum):
APPROVE = "APPROVE"
REJECT = "REJECT"
RETRY = "RETRY"
class RetryPolicy(StrictModel):
"""Bounded retry policy for a workflow node."""
retryable: bool = False
max_attempts: int = Field(default=1, ge=1, le=10)
@model_validator(mode="after")
def non_retryable_nodes_have_one_attempt(self) -> "RetryPolicy":
if not self.retryable and self.max_attempts != 1:
raise ValueError("non-retryable nodes must use max_attempts=1")
return self
class NodeDefinition(StrictModel):
"""Static definition of one node in a workflow."""
node_id: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=200)
node_type: NodeType
handler: str | None = Field(default=None, min_length=1, max_length=200)
depends_on: list[str] = Field(default_factory=list)
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
routes: dict[str, str] = Field(default_factory=dict)
config: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_decision_contract(self) -> "NodeDefinition":
if self.node_type is not NodeType.DECISION:
if self.routes:
raise ValueError("routes are only allowed on DECISION nodes")
return self
if self.handler is None:
raise ValueError("DECISION nodes must declare a handler")
if len(self.routes) < 2:
raise ValueError("DECISION nodes must declare at least two routes")
targets: list[str] = []
for route_label, target_node_id in self.routes.items():
if not route_label.strip() or len(route_label) > 100:
raise ValueError("decision route labels must be 1-100 non-blank characters")
if not target_node_id.strip() or len(target_node_id) > 100:
raise ValueError("decision route targets must be 1-100 non-blank characters")
targets.append(target_node_id)
if len(set(targets)) != len(targets):
raise ValueError("DECISION route targets must be unique")
return self
class WorkflowDefinition(StrictModel):
"""Static workflow definition supplied to the DAG validator/executor."""
workflow_id: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=200)
version: str = Field(default="1.0", min_length=1, max_length=50)
nodes: list[NodeDefinition] = Field(min_length=1)
class NodeRun(StrictModel):
"""Runtime state for one node execution within a workflow run."""
node_id: str = Field(min_length=1, max_length=100)
status: NodeStatus = NodeStatus.PENDING
attempt: int = Field(default=0, ge=0, le=10)
output: dict[str, Any] | None = None
error: str | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
class WorkflowEvent(StrictModel):
"""Structured audit event emitted when workflow state changes."""
event_id: str = Field(min_length=1, max_length=100)
run_id: str = Field(min_length=1, max_length=100)
event_type: EventType
node_id: str | None = Field(default=None, min_length=1, max_length=100)
details: dict[str, Any] = Field(default_factory=dict)
timestamp: datetime = Field(default_factory=utc_now)
class HumanReviewRequest(StrictModel):
"""Durable checkpoint requiring an explicit human decision."""
review_id: str = Field(min_length=1, max_length=100)
run_id: str = Field(min_length=1, max_length=100)
node_id: str = Field(min_length=1, max_length=100)
reason: str = Field(min_length=1, max_length=1000)
decision: HumanDecision | None = None
requested_at: datetime = Field(default_factory=utc_now)
decided_at: datetime | None = None
class WorkflowRun(StrictModel):
"""Durable runtime state, audit history, and human-review history for one run."""
run_id: str = Field(min_length=1, max_length=100)
workflow_id: str = Field(min_length=1, max_length=100)
workflow_version: str = Field(default="1.0", min_length=1, max_length=50)
status: WorkflowStatus = WorkflowStatus.PENDING
node_runs: dict[str, NodeRun] = Field(default_factory=dict)
context: dict[str, Any] = Field(default_factory=dict)
events: tuple[WorkflowEvent, ...] = Field(default_factory=tuple)
human_reviews: tuple[HumanReviewRequest, ...] = Field(default_factory=tuple)
final_output: dict[str, Any] | None = None
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)