PMFlow/backend/app/schemas/pmflow_ai_agent_runtime.py

1108 lines
49 KiB
Python

"""Customer-safe Agent Runtime contracts for Space PMFlow AI."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
AGENT_RUN_PACKET_VERSION = "pmflow_ai_agent_run.v1"
AGENT_WORKSPACE_VERSION = "pmflow_ai_agent_workspace.v1"
AGENT_STEP_VERSION = "pmflow_ai_agent_step.v1"
AGENT_OBSERVATION_VERSION = "pmflow_ai_agent_observation.v1"
AGENT_TOOL_CONTRACT_VERSION = "pmflow_ai_agent_tool_contract.v1"
AGENT_RUN_EVENT_VERSION = "pmflow_ai_agent_run_event.v1"
AGENT_RUN_BUDGET_VERSION = "pmflow_ai_agent_run_budget.v1"
AGENT_GOAL_FRAME_VERSION = "pmflow_ai_agent_goal_frame.v1"
AGENT_EVIDENCE_NORMALIZATION_VERSION = "pmflow_ai_agent_evidence_normalization.v1"
AGENT_TERMINAL_STATE_VERSION = "pmflow_ai_agent_terminal_answer_state.v1"
AGENT_ANSWER_RUNTIME_PACKET_VERSION = "pmflow_ai_agent_answer_runtime_packet.v1"
AGENT_DELIVERABLE_PACKET_VERSION = "pmflow_ai_agent_deliverable_packet.v1"
def new_agent_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex[:12]}"
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def clean_text(value: Any, *, limit: int = 800) -> str:
return " ".join(str(value or "").strip().split())[:limit]
class PMFlowAIAgentObservation(BaseModel):
"""One observation produced by the agent environment or a future tool."""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_OBSERVATION_VERSION
observation_id: str = Field(default_factory=lambda: new_agent_id("obs"), max_length=80)
source: str = Field(default="agent_runtime", max_length=80)
observation_type: str = Field(default="workspace_snapshot", max_length=80)
summary: str = Field(default="", max_length=1200)
facts: list[dict[str, Any]] = Field(default_factory=list)
sources: list[dict[str, Any]] = Field(default_factory=list)
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
relevance_to_goal: Literal["high", "medium", "low", "unknown"] = "unknown"
safe_for_customer: bool = True
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("observation_id", "source", "observation_type", "summary", mode="before")
@classmethod
def _normalize_text(cls, value: Any) -> str:
return clean_text(value, limit=1200)
class PMFlowAIAgentStep(BaseModel):
"""A visible agent step. These steps are the only source for process UI."""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_STEP_VERSION
step_id: str = Field(default_factory=lambda: new_agent_id("step"), max_length=80)
key: str = Field(..., min_length=1, max_length=80)
label: str = Field(..., min_length=1, max_length=160)
step_type: Literal[
"understand_goal",
"bind_context",
"observe_state",
"read_evidence",
"extract_claims",
"assess_sufficiency",
"recover_or_continue",
"prepare_action",
"wait_confirmation",
"workspace_snapshot",
"planner",
"tool",
"critic",
"answer",
] = "understand_goal"
status: Literal["pending", "active", "completed", "skipped"] = "completed"
selected_tool: str | None = Field(default=None, max_length=80)
observation_id: str | None = Field(default=None, max_length=80)
display_summary: str | None = Field(default=None, max_length=360)
duration_ms: int | None = Field(default=None, ge=0)
blocking: bool = False
@field_validator("step_id", "key", "label", "selected_tool", "observation_id", "display_summary", mode="before")
@classmethod
def _normalize_step_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=360)
class PMFlowAIAgentStepProposal(BaseModel):
"""Planner output for one next agent step.
This is intentionally a runtime action contract, not a business scenario
classifier. Tool names are validated by the runtime tool registry.
"""
model_config = ConfigDict(extra="ignore")
action: Literal["tool", "answer", "ask_user", "stop_blocked"] = "tool"
tool_name: str | None = Field(default=None, max_length=80)
tool_input: dict[str, Any] = Field(default_factory=dict)
rationale: str = Field(default="", max_length=1000)
expected_observation: str = Field(default="", max_length=1000)
stop_condition: str = Field(default="", max_length=1000)
risks_if_answer_now: list[str] = Field(default_factory=list)
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
@field_validator("tool_name", "rationale", "expected_observation", "stop_condition", mode="before")
@classmethod
def _normalize_proposal_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=1000)
@field_validator("risks_if_answer_now", mode="before")
@classmethod
def _normalize_risk_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
out: list[str] = []
for item in rows:
text = clean_text(item, limit=360)
if text and text not in out:
out.append(text)
return out[:8]
class PMFlowAIAgentToolContract(BaseModel):
"""Typed capability contract for an Agent Runtime tool."""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_TOOL_CONTRACT_VERSION
tool_name: str = Field(..., min_length=1, max_length=80)
aliases: list[str] = Field(default_factory=list)
capability: str = Field(default="", max_length=240)
input_schema: dict[str, Any] = Field(default_factory=dict)
output_schema: dict[str, Any] = Field(default_factory=dict)
risk_level: Literal["low", "medium", "high"] = "low"
read_or_write: Literal["read", "write"] = "read"
preconditions: list[str] = Field(default_factory=list)
permission_boundary: str = Field(default="", max_length=500)
success_signal: str = Field(default="", max_length=300)
failure_signal: str = Field(default="", max_length=300)
side_effects: list[str] = Field(default_factory=list)
customer_safe_projection: dict[str, Any] = Field(default_factory=dict)
@field_validator("tool_name", "capability", "permission_boundary", "success_signal", "failure_signal", mode="before")
@classmethod
def _normalize_contract_text(cls, value: Any) -> str:
return clean_text(value, limit=500)
@field_validator("aliases", "preconditions", "side_effects", mode="before")
@classmethod
def _normalize_contract_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
out: list[str] = []
for item in rows:
text = clean_text(item, limit=240)
if text and text not in out:
out.append(text)
return out[:16]
class PMFlowAIAgentRunBudget(BaseModel):
"""Runtime budget for one bounded agent loop."""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_RUN_BUDGET_VERSION
max_iterations: int = Field(default=4, ge=1, le=8)
max_tool_calls: int = Field(default=6, ge=0, le=8)
max_repeated_tool_signatures: int = Field(default=0, ge=0, le=3)
class PMFlowAIAgentRunEvent(BaseModel):
"""Customer-safe execution event emitted by the agent loop."""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_RUN_EVENT_VERSION
event_id: str = Field(default_factory=lambda: new_agent_id("evt"), max_length=80)
event_type: Literal[
"goal_frame",
"workspace_snapshot",
"plan",
"tool_observation",
"evidence_normalized",
"critic_review",
"runtime_stop",
"answer",
] = "workspace_snapshot"
iteration: int = Field(default=0, ge=0)
action: str | None = Field(default=None, max_length=80)
tool_name: str | None = Field(default=None, max_length=80)
observation_id: str | None = Field(default=None, max_length=80)
state_changed: bool = False
duration_ms: int | None = Field(default=None, ge=0)
summary: str = Field(default="", max_length=800)
stop_reason: str | None = Field(default=None, max_length=120)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("event_id", "action", "tool_name", "observation_id", "summary", "stop_reason", mode="before")
@classmethod
def _normalize_event_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=800)
class PMFlowAIAgentCriticResult(BaseModel):
"""State critic result after observing a tool result."""
model_config = ConfigDict(extra="ignore")
ready_to_answer: bool = False
should_continue: bool = False
needs_user_input: bool = False
retry_would_change_state: bool = True
state_delta: dict[str, Any] = Field(default_factory=dict)
findings: list[str] = Field(default_factory=list)
missing_state: list[str] = Field(default_factory=list)
recover_or_continue: Literal["continue_with_tool", "narrow_scope", "ask_user", "stop_blocked", "compose_answer"] = "continue_with_tool"
next_step_hint: str = Field(default="", max_length=1000)
reason: str = Field(default="", max_length=1000)
@field_validator("findings", "missing_state", mode="before")
@classmethod
def _normalize_text_rows(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
out: list[str] = []
for item in rows:
text = clean_text(item, limit=360)
if text and text not in out:
out.append(text)
return out[:12]
@field_validator("next_step_hint", "reason", mode="before")
@classmethod
def _normalize_critic_text(cls, value: Any) -> str:
return clean_text(value, limit=1000)
class PMFlowAIAgentGoalFrame(BaseModel):
"""LLM-authored goal contract for one agent turn.
This is not a business scenario enum. It captures the user's goal,
objects/evidence to bind, success criteria and hard boundaries so the
runtime can validate and constrain subsequent generic agent primitives.
"""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_GOAL_FRAME_VERSION
frame_id: str = Field(default_factory=lambda: new_agent_id("goal"), max_length=80)
user_goal: str = Field(default="", max_length=1200)
desired_output: str = Field(default="", max_length=1200)
interpretation_candidates: list[dict[str, Any]] = Field(default_factory=list)
interpretation_branches: list[dict[str, Any]] = Field(default_factory=list)
selected_interpretation: str = Field(default="", max_length=600)
answer_policy: Literal["directional_answer", "partial_answer", "strict_verified", "official_only", "clarify_first"] = "directional_answer"
source_policy: Literal["credible_public_sources", "official_preferred", "official_only", "internal_customer_safe"] = "credible_public_sources"
freshness_policy: Literal["prefer_recent_not_strict", "strict_time_window", "not_required"] = "prefer_recent_not_strict"
missing_evidence_policy: Literal["answer_with_boundaries", "fail_closed", "ask_user"] = "answer_with_boundaries"
objects_to_bind: list[str] = Field(default_factory=list)
evidence_requirements: list[str] = Field(default_factory=list)
success_criteria: list[str] = Field(default_factory=list)
boundaries: list[str] = Field(default_factory=list)
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
@field_validator("frame_id", "user_goal", "desired_output", "selected_interpretation", mode="before")
@classmethod
def _normalize_goal_text(cls, value: Any) -> str:
return clean_text(value, limit=1200)
@field_validator("answer_policy", mode="before")
@classmethod
def _normalize_answer_policy(cls, value: Any) -> str:
text = clean_text(value, limit=80)
return text if text in {"directional_answer", "partial_answer", "strict_verified", "official_only", "clarify_first"} else "directional_answer"
@field_validator("source_policy", mode="before")
@classmethod
def _normalize_source_policy(cls, value: Any) -> str:
text = clean_text(value, limit=80)
return text if text in {"credible_public_sources", "official_preferred", "official_only", "internal_customer_safe"} else "credible_public_sources"
@field_validator("freshness_policy", mode="before")
@classmethod
def _normalize_freshness_policy(cls, value: Any) -> str:
text = clean_text(value, limit=80)
return text if text in {"prefer_recent_not_strict", "strict_time_window", "not_required"} else "prefer_recent_not_strict"
@field_validator("missing_evidence_policy", mode="before")
@classmethod
def _normalize_missing_evidence_policy(cls, value: Any) -> str:
text = clean_text(value, limit=80)
return text if text in {"answer_with_boundaries", "fail_closed", "ask_user"} else "answer_with_boundaries"
@field_validator("interpretation_candidates", mode="before")
@classmethod
def _normalize_interpretation_candidates(cls, value: Any) -> list[dict[str, Any]]:
rows = value if isinstance(value, list) else []
out: list[dict[str, Any]] = []
for item in rows[:6]:
if isinstance(item, dict):
meaning = clean_text(item.get("meaning") or item.get("summary") or item.get("label"), limit=360)
confidence = clean_text(item.get("confidence") or "unknown", limit=40)
if meaning:
out.append(
{
"meaning": meaning,
"confidence": confidence if confidence in {"high", "medium", "low", "unknown"} else "unknown",
}
)
continue
meaning = clean_text(item, limit=360)
if meaning:
out.append({"meaning": meaning, "confidence": "unknown"})
return out
@field_validator("interpretation_branches", mode="before")
@classmethod
def _normalize_interpretation_branches(cls, value: Any) -> list[dict[str, Any]]:
rows = value if isinstance(value, list) else []
out: list[dict[str, Any]] = []
for index, item in enumerate(rows[:6], start=1):
if not isinstance(item, dict):
meaning = clean_text(item, limit=500)
if meaning:
out.append(
{
"branch_id": f"b{index}",
"meaning": meaning,
"confidence": "unknown",
"user_language_prior": "unknown",
"anchor_terms": [],
"missing_anchors": [],
}
)
continue
meaning = clean_text(item.get("meaning") or item.get("summary") or item.get("label"), limit=500)
if not meaning:
continue
branch_id = clean_text(item.get("branch_id") or item.get("id") or f"b{index}", limit=80)
confidence = clean_text(item.get("confidence") or "unknown", limit=40)
prior = clean_text(item.get("user_language_prior") or item.get("language_prior") or confidence or "unknown", limit=40)
out.append(
{
"branch_id": branch_id or f"b{index}",
"meaning": meaning,
"semantic_decomposition": clean_text(item.get("semantic_decomposition") or "", limit=500),
"confidence": confidence if confidence in {"high", "medium", "low", "unknown"} else "unknown",
"user_language_prior": prior if prior in {"high", "medium", "low", "unknown"} else "unknown",
"anchor_terms": [
clean_text(term, limit=80)
for term in list(item.get("anchor_terms") or [])[:8]
if clean_text(term, limit=80)
],
"missing_anchors": [
clean_text(term, limit=120)
for term in list(item.get("missing_anchors") or [])[:8]
if clean_text(term, limit=120)
],
}
)
return out
@model_validator(mode="after")
def _ensure_interpretation_branches(self) -> "PMFlowAIAgentGoalFrame":
if not self.interpretation_branches and self.interpretation_candidates:
self.interpretation_branches = [
{
"branch_id": f"b{index}",
"meaning": str(item.get("meaning") or "")[:500],
"confidence": str(item.get("confidence") or "unknown"),
"user_language_prior": str(item.get("confidence") or "unknown"),
"anchor_terms": [],
"missing_anchors": [],
}
for index, item in enumerate(self.interpretation_candidates[:6], start=1)
if str(item.get("meaning") or "").strip()
]
return self
@field_validator("objects_to_bind", "evidence_requirements", "success_criteria", "boundaries", mode="before")
@classmethod
def _normalize_goal_lists(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
out: list[str] = []
for item in rows:
text = clean_text(item, limit=500)
if text and text not in out:
out.append(text)
return out[:16]
class PMFlowAIAgentClaim(BaseModel):
model_config = ConfigDict(extra="ignore")
claim_id: str = Field(default_factory=lambda: new_agent_id("claim"), max_length=80)
claim_type: str = Field(default="observation_claim", max_length=80)
claim_text: str = Field(default="", max_length=1200)
customer_value: str = Field(default="", max_length=500)
support_refs: list[str] = Field(default_factory=list)
interpretation_branch_id: str | None = Field(default=None, max_length=80)
relevance_to_user_goal: Literal["direct", "partial", "off_branch", "unknown"] = "unknown"
claim_kind: Literal["substantive_fact", "directional_judgement", "source_candidate", "boundary", "unknown"] = "unknown"
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
allowed_to_state: bool = True
@field_validator("claim_id", "claim_type", "claim_text", "customer_value", "interpretation_branch_id", mode="before")
@classmethod
def _normalize_claim_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=1200)
@field_validator("relevance_to_user_goal", mode="before")
@classmethod
def _normalize_claim_relevance(cls, value: Any) -> str:
text = clean_text(value, limit=40)
return text if text in {"direct", "partial", "off_branch", "unknown"} else "unknown"
@field_validator("claim_kind", mode="before")
@classmethod
def _normalize_claim_kind(cls, value: Any) -> str:
text = clean_text(value, limit=60)
return text if text in {"substantive_fact", "directional_judgement", "source_candidate", "boundary", "unknown"} else "unknown"
@field_validator("support_refs", mode="before")
@classmethod
def _normalize_support_refs(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
class PMFlowAIAgentBoundary(BaseModel):
model_config = ConfigDict(extra="ignore")
boundary_id: str = Field(default_factory=lambda: new_agent_id("boundary"), max_length=80)
boundary_type: str = Field(default="evidence_boundary", max_length=80)
blocked_claim: str = Field(default="", max_length=1000)
customer_safe_reason: str = Field(default="", max_length=1000)
allowed_alternative: str = Field(default="", max_length=1000)
support_refs: list[str] = Field(default_factory=list)
@field_validator("boundary_id", "boundary_type", "blocked_claim", "customer_safe_reason", "allowed_alternative", mode="before")
@classmethod
def _normalize_boundary_text(cls, value: Any) -> str:
return clean_text(value, limit=1000)
@field_validator("support_refs", mode="before")
@classmethod
def _normalize_support_refs(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
class PMFlowAIAgentSourceNormalization(BaseModel):
"""Per-source normalization result.
This is a customer-safe projection, not raw source storage. Raw snippets stay
inside observations and must not be consumed by the answer runtime.
"""
model_config = ConfigDict(extra="ignore")
source_ref: str = Field(default="", max_length=300)
source_title: str = Field(default="", max_length=240)
source_quality: Literal["usable", "noisy", "blocked", "off_topic", "duplicate"] = "noisy"
usable_claims: list[PMFlowAIAgentClaim] = Field(default_factory=list)
boundaries: list[PMFlowAIAgentBoundary] = Field(default_factory=list)
gaps: list[str] = Field(default_factory=list)
rejected_reason: str = Field(default="", max_length=1000)
@field_validator("source_ref", "source_title", "rejected_reason", mode="before")
@classmethod
def _normalize_source_text(cls, value: Any) -> str:
return clean_text(value, limit=1000)
@field_validator("gaps", mode="before")
@classmethod
def _normalize_source_gap_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=360) for item in rows[:12] if clean_text(item, limit=360)]
@model_validator(mode="after")
def _enforce_quality_contract(self) -> "PMFlowAIAgentSourceNormalization":
if self.source_quality != "usable":
self.usable_claims = []
return self
class PMFlowAIAgentEvidenceNormalization(BaseModel):
"""Normalized evidence projection from one raw observation."""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_EVIDENCE_NORMALIZATION_VERSION
normalization_id: str = Field(default_factory=lambda: new_agent_id("evnorm"), max_length=80)
source_observation_id: str | None = Field(default=None, max_length=80)
source_quality: Literal["usable", "noisy", "blocked", "off_topic", "duplicate"] = "noisy"
source_normalizations: list[PMFlowAIAgentSourceNormalization] = Field(default_factory=list)
usable_claims: list[PMFlowAIAgentClaim] = Field(default_factory=list)
affordances: list[PMFlowAIAgentAffordanceLedgerEntry] = Field(default_factory=list)
action_candidates: list[PMFlowAIAgentActionCandidateLedgerEntry] = Field(default_factory=list)
boundaries: list[PMFlowAIAgentBoundary] = Field(default_factory=list)
gaps: list[str] = Field(default_factory=list)
rejected_reason: str = Field(default="", max_length=1000)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("normalization_id", "source_observation_id", "rejected_reason", mode="before")
@classmethod
def _normalize_normalization_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=1000)
@field_validator("gaps", mode="before")
@classmethod
def _normalize_gap_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=360) for item in rows[:12] if clean_text(item, limit=360)]
@model_validator(mode="after")
def _enforce_quality_contract(self) -> "PMFlowAIAgentEvidenceNormalization":
if self.source_quality != "usable":
self.usable_claims = []
return self
class PMFlowAIAgentAttempt(BaseModel):
model_config = ConfigDict(extra="ignore")
attempt_id: str = Field(default_factory=lambda: new_agent_id("attempt"), max_length=80)
action: str = Field(default="", max_length=120)
outcome: str = Field(default="", max_length=500)
observation_id: str | None = Field(default=None, max_length=80)
@field_validator("attempt_id", "action", "outcome", "observation_id", mode="before")
@classmethod
def _normalize_attempt_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=500)
class PMFlowAIAgentAnswerIntentPlan(BaseModel):
model_config = ConfigDict(extra="ignore")
intent_id: str = Field(default="agent_answer_intent", max_length=80)
lead_claim_id: str = Field(default="", max_length=80)
lead_boundary_id: str = Field(default="", max_length=80)
detail_level: Literal["brief", "standard", "deep"] = "standard"
structure_preference: str = Field(default="short_paragraphs_no_heading", max_length=120)
answer_moves: list[str] = Field(default_factory=list)
composition_guidance: list[str] = Field(default_factory=list)
anti_template_rules: list[str] = Field(default_factory=list)
must_include_claim_ids: list[str] = Field(default_factory=list)
must_include_boundary_ids: list[str] = Field(default_factory=list)
avoid_sections: bool = True
@field_validator("intent_id", "lead_claim_id", "lead_boundary_id", "structure_preference", mode="before")
@classmethod
def _normalize_plan_text(cls, value: Any) -> str:
return clean_text(value, limit=160)
@field_validator("answer_moves", "composition_guidance", "anti_template_rules", "must_include_claim_ids", "must_include_boundary_ids", mode="before")
@classmethod
def _normalize_plan_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=260) for item in rows[:12] if clean_text(item, limit=260)]
class PMFlowAIAgentTerminalAnswerState(BaseModel):
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_TERMINAL_STATE_VERSION
visibility: Literal["customer"] = "customer"
user_goal: str = Field(default="", max_length=800)
terminal_status: Literal["answerable", "partially_answerable", "waiting_for_evidence", "waiting_for_user", "refused_by_boundary", "blocked"] = "answerable"
claims: list[PMFlowAIAgentClaim] = Field(default_factory=list)
boundaries: list[PMFlowAIAgentBoundary] = Field(default_factory=list)
attempts: list[PMFlowAIAgentAttempt] = Field(default_factory=list)
action_candidates: list[PMFlowAIAgentActionCandidateLedgerEntry] = Field(default_factory=list)
deliverable_plan: PMFlowAIAgentDeliverablePlan | None = None
answer_intent_plan: PMFlowAIAgentAnswerIntentPlan = Field(default_factory=PMFlowAIAgentAnswerIntentPlan)
remaining_gaps: list[str] = Field(default_factory=list)
next_possible_actions: list[str] = Field(default_factory=list)
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
@field_validator("user_goal", mode="before")
@classmethod
def _normalize_goal(cls, value: Any) -> str:
return clean_text(value, limit=800)
@field_validator("remaining_gaps", "next_possible_actions", mode="before")
@classmethod
def _normalize_terminal_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=360) for item in rows[:12] if clean_text(item, limit=360)]
class PMFlowAIAgentAnswerDraft(BaseModel):
model_config = ConfigDict(extra="ignore")
draft_id: str = Field(default_factory=lambda: new_agent_id("draft"), max_length=80)
answer_text: str = Field(default="", max_length=6000)
source: Literal["llm_composer", "llm_reviser", "terminal_fallback", "terminal_repair"] = "terminal_fallback"
quality_findings: list[str] = Field(default_factory=list)
@field_validator("draft_id", "answer_text", mode="before")
@classmethod
def _normalize_draft_text(cls, value: Any) -> str:
return clean_text(value, limit=6000)
@field_validator("quality_findings", mode="before")
@classmethod
def _normalize_quality_findings(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
class PMFlowAIAgentAnswerRuntimePacket(BaseModel):
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_ANSWER_RUNTIME_PACKET_VERSION
objective: dict[str, Any] = Field(default_factory=dict)
terminal_state: PMFlowAIAgentTerminalAnswerState
draft: PMFlowAIAgentAnswerDraft
quality_findings: list[str] = Field(default_factory=list)
@field_validator("quality_findings", mode="before")
@classmethod
def _normalize_quality_findings(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
class PMFlowAIAgentDeliverableSection(BaseModel):
model_config = ConfigDict(extra="ignore")
section_id: str = Field(default_factory=lambda: new_agent_id("dsec"), max_length=80)
section_kind: str = Field(default="summary", max_length=120)
display_title: str = Field(default="", max_length=160)
items: list[dict[str, Any]] = Field(default_factory=list)
source_fact_ids: list[str] = Field(default_factory=list)
source_observation_ids: list[str] = Field(default_factory=list)
@field_validator("section_id", "section_kind", "display_title", mode="before")
@classmethod
def _normalize_section_text(cls, value: Any) -> str:
return clean_text(value, limit=160)
@field_validator("source_fact_ids", "source_observation_ids", mode="before")
@classmethod
def _normalize_section_refs(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=160) for item in rows[:24] if clean_text(item, limit=160)]
class PMFlowAIAgentDeliverablePacket(BaseModel):
"""Customer-safe structured deliverable produced by the agent run."""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_DELIVERABLE_PACKET_VERSION
visibility: Literal["customer"] = "customer"
deliverable_id: str = Field(default_factory=lambda: new_agent_id("deliv"), max_length=80)
deliverable_kind: str = Field(default="direct_answer", max_length=120)
status: str = Field(default="completed", max_length=80)
title: str = Field(default="", max_length=240)
summary: str = Field(default="", max_length=1200)
answer_text: str = Field(default="", max_length=6000)
sections: list[PMFlowAIAgentDeliverableSection] = Field(default_factory=list)
object_refs: list[str] = Field(default_factory=list)
source_fact_ids: list[str] = Field(default_factory=list)
source_observation_ids: list[str] = Field(default_factory=list)
pending_confirmations: list[dict[str, Any]] = Field(default_factory=list)
blocked_reasons: list[str] = Field(default_factory=list)
next_actions: list[str] = Field(default_factory=list)
confidence: str = Field(default="unknown", max_length=40)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("deliverable_id", "deliverable_kind", "status", "title", "summary", "answer_text", "confidence", mode="before")
@classmethod
def _normalize_deliverable_packet_text(cls, value: Any) -> str:
return clean_text(value, limit=6000)
@field_validator("object_refs", "source_fact_ids", "source_observation_ids", "blocked_reasons", "next_actions", mode="before")
@classmethod
def _normalize_deliverable_packet_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=360) for item in rows[:24] if clean_text(item, limit=360)]
class PMFlowAIAgentObjectiveLedgerEntry(BaseModel):
model_config = ConfigDict(extra="ignore")
objective_id: str = Field(default_factory=lambda: new_agent_id("obj"), max_length=80)
goal: str = Field(default="", max_length=1200)
status: str = Field(default="active", max_length=80)
source: str = Field(default="user", max_length=80)
created_at: str = Field(default_factory=utc_now_iso)
updated_at: str = Field(default_factory=utc_now_iso)
@field_validator("objective_id", "goal", "status", "source", mode="before")
@classmethod
def _normalize_objective_text(cls, value: Any) -> str:
return clean_text(value, limit=1200)
class PMFlowAIAgentObjectLedgerEntry(BaseModel):
model_config = ConfigDict(extra="ignore")
object_ref: str = Field(default="", max_length=160)
object_type: str = Field(default="", max_length=80)
title: str = Field(default="", max_length=240)
binding_strength: str = Field(default="", max_length=80)
href: str = Field(default="", max_length=300)
source: str = Field(default="", max_length=120)
available_affordances: list[str] = Field(default_factory=list)
source_observation_id: str | None = Field(default=None, max_length=80)
last_seen_at: str = Field(default_factory=utc_now_iso)
@field_validator("object_ref", "object_type", "title", "binding_strength", "href", "source", "source_observation_id", mode="before")
@classmethod
def _normalize_object_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=240)
@field_validator("available_affordances", mode="before")
@classmethod
def _normalize_affordance_names(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=120) for item in rows[:12] if clean_text(item, limit=120)]
class PMFlowAIAgentAffordanceLedgerEntry(BaseModel):
model_config = ConfigDict(extra="ignore")
affordance_id: str = Field(default_factory=lambda: new_agent_id("aff"), max_length=80)
object_ref: str = Field(default="", max_length=160)
title: str = Field(default="", max_length=240)
action_label: str = Field(default="", max_length=240)
reason: str = Field(default="", max_length=600)
href: str = Field(default="", max_length=300)
safety: str = Field(default="suggest_only", max_length=80)
requires_confirmation: bool = True
source_observation_id: str | None = Field(default=None, max_length=80)
evidence_refs: list[str] = Field(default_factory=list)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("affordance_id", "object_ref", "title", "action_label", "reason", "href", "safety", "source_observation_id", mode="before")
@classmethod
def _normalize_affordance_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=600)
@field_validator("evidence_refs", mode="before")
@classmethod
def _normalize_affordance_refs(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=180) for item in rows[:16] if clean_text(item, limit=180)]
class PMFlowAIAgentActionCandidateLedgerEntry(BaseModel):
model_config = ConfigDict(extra="ignore")
candidate_id: str = Field(default_factory=lambda: new_agent_id("cand"), max_length=80)
title: str = Field(default="", max_length=240)
summary: str = Field(default="", max_length=800)
target_ref: str = Field(default="", max_length=160)
target_title: str = Field(default="", max_length=240)
href: str = Field(default="", max_length=300)
reason: str = Field(default="", max_length=800)
completion_criteria: str = Field(default="", max_length=800)
safety: str = Field(default="suggest_only", max_length=80)
requires_confirmation: bool = True
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
source_observation_id: str | None = Field(default=None, max_length=80)
evidence_refs: list[str] = Field(default_factory=list)
blocked_reasons: list[str] = Field(default_factory=list)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator(
"candidate_id",
"title",
"summary",
"target_ref",
"target_title",
"href",
"reason",
"completion_criteria",
"safety",
"source_observation_id",
mode="before",
)
@classmethod
def _normalize_candidate_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=800)
@field_validator("evidence_refs", "blocked_reasons", mode="before")
@classmethod
def _normalize_candidate_lists(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=240) for item in rows[:16] if clean_text(item, limit=240)]
class PMFlowAIAgentDeliverablePlan(BaseModel):
model_config = ConfigDict(extra="ignore")
plan_id: str = Field(default_factory=lambda: new_agent_id("dplan"), max_length=80)
user_goal: str = Field(default="", max_length=800)
desired_customer_result: str = Field(default="", max_length=800)
composition_guidance: list[str] = Field(default_factory=list)
required_materials: list[str] = Field(default_factory=list)
specificity_requirements: list[str] = Field(default_factory=list)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("plan_id", "user_goal", "desired_customer_result", mode="before")
@classmethod
def _normalize_deliverable_plan_text(cls, value: Any) -> str:
return clean_text(value, limit=800)
@field_validator("composition_guidance", "required_materials", "specificity_requirements", mode="before")
@classmethod
def _normalize_deliverable_plan_lists(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=260) for item in rows[:16] if clean_text(item, limit=260)]
class PMFlowAIAgentFactLedgerEntry(BaseModel):
model_config = ConfigDict(extra="ignore")
fact_id: str = Field(default_factory=lambda: new_agent_id("fact"), max_length=80)
fact_type: str = Field(default="", max_length=120)
fact_text: str = Field(default="", max_length=1200)
object_ref: str | None = Field(default=None, max_length=160)
source_tool: str | None = Field(default=None, max_length=80)
source_observation_id: str | None = Field(default=None, max_length=80)
confidence: str = Field(default="unknown", max_length=40)
support_refs: list[str] = Field(default_factory=list)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("fact_id", "fact_type", "fact_text", "object_ref", "source_tool", "source_observation_id", "confidence", mode="before")
@classmethod
def _normalize_fact_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=1200)
@field_validator("support_refs", mode="before")
@classmethod
def _normalize_fact_refs(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
class PMFlowAIAgentGapLedgerEntry(BaseModel):
model_config = ConfigDict(extra="ignore")
gap_id: str = Field(default_factory=lambda: new_agent_id("gap"), max_length=80)
gap_type: str = Field(default="", max_length=120)
description: str = Field(default="", max_length=800)
blocking: bool = False
user_actionable: bool = False
source: str | None = Field(default=None, max_length=120)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("gap_id", "gap_type", "description", "source", mode="before")
@classmethod
def _normalize_gap_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=800)
class PMFlowAIAgentAttemptLedgerEntry(BaseModel):
model_config = ConfigDict(extra="ignore")
attempt_id: str = Field(default_factory=lambda: new_agent_id("try"), max_length=80)
action: str = Field(default="", max_length=120)
tool_name: str | None = Field(default=None, max_length=80)
outcome: str = Field(default="", max_length=800)
observation_id: str | None = Field(default=None, max_length=80)
state_changed: bool = False
duration_ms: int | None = Field(default=None, ge=0)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("attempt_id", "action", "tool_name", "outcome", "observation_id", mode="before")
@classmethod
def _normalize_attempt_ledger_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=800)
class PMFlowAIAgentActionLedgerEntry(BaseModel):
model_config = ConfigDict(extra="ignore")
action_id: str = Field(default_factory=lambda: new_agent_id("act"), max_length=80)
action: str = Field(default="", max_length=120)
tool_name: str | None = Field(default=None, max_length=80)
status: str = Field(default="proposed", max_length=80)
rationale: str = Field(default="", max_length=1000)
expected_observation: str = Field(default="", max_length=1000)
requires_confirmation: bool = False
risk_level: str = Field(default="low", max_length=40)
created_at: str = Field(default_factory=utc_now_iso)
@field_validator("action_id", "action", "tool_name", "status", "rationale", "expected_observation", "risk_level", mode="before")
@classmethod
def _normalize_action_ledger_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=1000)
class PMFlowAIAgentDeliverableState(BaseModel):
model_config = ConfigDict(extra="ignore")
deliverable_kind: str = Field(default="answer", max_length=120)
status: str = Field(default="pending", max_length=80)
summary: str = Field(default="", max_length=1200)
stop_reason: str | None = Field(default=None, max_length=120)
source_observation_id: str | None = Field(default=None, max_length=80)
updated_at: str = Field(default_factory=utc_now_iso)
@field_validator("deliverable_kind", "status", "summary", "stop_reason", "source_observation_id", mode="before")
@classmethod
def _normalize_deliverable_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=1200)
class PMFlowAIAgentWorkspace(BaseModel):
"""Long-lived working memory for a PMFlow AI agent run."""
model_config = ConfigDict(extra="ignore")
workspace_version: str = AGENT_WORKSPACE_VERSION
workspace_id: str = Field(default_factory=lambda: new_agent_id("aw"), max_length=80)
current_goal: str = Field(default="", max_length=1200)
goal_history: list[str] = Field(default_factory=list)
objective_ledger: list[PMFlowAIAgentObjectiveLedgerEntry] = Field(default_factory=list)
goal_frame: PMFlowAIAgentGoalFrame | None = None
object_ledger: list[PMFlowAIAgentObjectLedgerEntry] = Field(default_factory=list)
affordance_ledger: list[PMFlowAIAgentAffordanceLedgerEntry] = Field(default_factory=list)
action_candidate_ledger: list[PMFlowAIAgentActionCandidateLedgerEntry] = Field(default_factory=list)
deliverable_plan: PMFlowAIAgentDeliverablePlan | None = None
fact_ledger: list[PMFlowAIAgentFactLedgerEntry] = Field(default_factory=list)
claim_ledger: list[PMFlowAIAgentClaim] = Field(default_factory=list)
boundary_ledger: list[PMFlowAIAgentBoundary] = Field(default_factory=list)
gap_ledger: list[PMFlowAIAgentGapLedgerEntry] = Field(default_factory=list)
attempt_ledger: list[PMFlowAIAgentAttemptLedgerEntry] = Field(default_factory=list)
action_ledger: list[PMFlowAIAgentActionLedgerEntry] = Field(default_factory=list)
deliverable_state: PMFlowAIAgentDeliverableState = Field(default_factory=PMFlowAIAgentDeliverableState)
known_facts: list[dict[str, Any]] = Field(default_factory=list)
observations: list[PMFlowAIAgentObservation] = Field(default_factory=list)
evidence_normalizations: list[PMFlowAIAgentEvidenceNormalization] = Field(default_factory=list)
open_questions: list[str] = Field(default_factory=list)
assumptions: list[str] = Field(default_factory=list)
rejected_bindings: list[dict[str, Any]] = Field(default_factory=list)
evidence_requirements: list[str] = Field(default_factory=list)
candidate_next_actions: list[dict[str, Any]] = Field(default_factory=list)
answer_attempts: list[dict[str, Any]] = Field(default_factory=list)
verification_failures: list[dict[str, Any]] = Field(default_factory=list)
status: Literal["active", "waiting_for_tools", "waiting_for_user", "answered", "blocked"] = "active"
updated_at: str = Field(default_factory=utc_now_iso)
@field_validator("workspace_id", "current_goal", mode="before")
@classmethod
def _normalize_workspace_text(cls, value: Any) -> str:
return clean_text(value, limit=1200)
@field_validator("goal_history", "open_questions", "assumptions", "evidence_requirements", mode="before")
@classmethod
def _normalize_text_list(cls, value: Any) -> list[str]:
rows = value if isinstance(value, list) else []
out: list[str] = []
for item in rows:
text = clean_text(item, limit=360)
if text and text not in out:
out.append(text)
return out[:24]
class PMFlowAIAgentRunPacket(BaseModel):
"""Customer-safe summary of one agent run."""
model_config = ConfigDict(extra="ignore")
packet_version: str = AGENT_RUN_PACKET_VERSION
visibility: Literal["customer"] = "customer"
run_id: str = Field(default_factory=lambda: new_agent_id("arun"), max_length=80)
session_id: int | None = None
turn_index: int | None = None
status: Literal["active", "waiting_for_tools", "waiting_for_user", "answered", "blocked"] = "active"
user_goal: str = Field(default="", max_length=1200)
workspace_id: str = Field(default="", max_length=80)
run_budget: PMFlowAIAgentRunBudget = Field(default_factory=PMFlowAIAgentRunBudget)
stop_reason: str | None = Field(default=None, max_length=120)
iterations: int = Field(default=0, ge=0)
tool_call_count: int = Field(default=0, ge=0)
steps: list[PMFlowAIAgentStep] = Field(default_factory=list)
observations: list[PMFlowAIAgentObservation] = Field(default_factory=list)
event_log: list[PMFlowAIAgentRunEvent] = Field(default_factory=list)
created_at: str = Field(default_factory=utc_now_iso)
completed_at: str | None = None
@field_validator("run_id", "user_goal", "workspace_id", "stop_reason", mode="before")
@classmethod
def _normalize_run_text(cls, value: Any) -> str | None:
if value is None:
return None
return clean_text(value, limit=1200)
def normalize_agent_workspace_payload(value: Any, *, fallback_goal: str = "") -> PMFlowAIAgentWorkspace:
if isinstance(value, PMFlowAIAgentWorkspace):
return value
if isinstance(value, dict) and value:
try:
return PMFlowAIAgentWorkspace.model_validate(value)
except Exception:
pass
return PMFlowAIAgentWorkspace(current_goal=clean_text(fallback_goal, limit=1200))
def customer_process_packet_from_agent_steps(
steps: list[PMFlowAIAgentStep],
*,
total_duration_ms: int | None = None,
) -> dict[str, Any]:
return {
"packet_version": "customer_answer_process.v1",
"visibility": "customer",
"answer_type": "agent_run",
"route_display": "agent_runtime",
"steps": [
{
"key": step.key,
"label": step.label,
"status": step.status,
"display_summary": step.display_summary,
"duration_ms": step.duration_ms,
"blocking": step.blocking,
}
for step in steps
],
"total_duration_ms": max(0, int(total_duration_ms or 0)) if total_duration_ms is not None else None,
}
__all__ = [
"PMFlowAIAgentAnswerDraft",
"PMFlowAIAgentAnswerIntentPlan",
"PMFlowAIAgentAnswerRuntimePacket",
"PMFlowAIAgentAttempt",
"PMFlowAIAgentBoundary",
"PMFlowAIAgentClaim",
"PMFlowAIAgentCriticResult",
"PMFlowAIAgentEvidenceNormalization",
"PMFlowAIAgentGoalFrame",
"PMFlowAIAgentActionLedgerEntry",
"PMFlowAIAgentActionCandidateLedgerEntry",
"PMFlowAIAgentAffordanceLedgerEntry",
"PMFlowAIAgentAttemptLedgerEntry",
"PMFlowAIAgentDeliverablePlan",
"PMFlowAIAgentDeliverablePacket",
"PMFlowAIAgentDeliverableSection",
"PMFlowAIAgentDeliverableState",
"PMFlowAIAgentFactLedgerEntry",
"PMFlowAIAgentGapLedgerEntry",
"PMFlowAIAgentObjectLedgerEntry",
"PMFlowAIAgentObjectiveLedgerEntry",
"PMFlowAIAgentObservation",
"PMFlowAIAgentRunBudget",
"PMFlowAIAgentRunEvent",
"PMFlowAIAgentRunPacket",
"PMFlowAIAgentStep",
"PMFlowAIAgentStepProposal",
"PMFlowAIAgentSourceNormalization",
"PMFlowAIAgentTerminalAnswerState",
"PMFlowAIAgentToolContract",
"PMFlowAIAgentWorkspace",
"customer_process_packet_from_agent_steps",
"new_agent_id",
"normalize_agent_workspace_payload",
]