"""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", ]