PMFlow/backend/app/schemas/pmflow_ai_agent_runtime.py

1756 lines
75 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Customer-safe Agent Runtime contracts for Space PMFlow AI."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Any, Literal
from urllib.parse import unquote, urlparse
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, 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]
def clean_markdown_text(value: Any, *, limit: int = 6000) -> str:
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not text:
return ""
lines = [re.sub(r"[ \t]+", " ", line).rstrip() for line in text.split("\n")]
normalized = "\n".join(lines).strip()
normalized = re.sub(r"\n{3,}", "\n\n", normalized)
return normalized[:limit]
def _normalize_goal_value(value: Any) -> Any:
if isinstance(value, dict):
out = {
clean_text(key, limit=80): _normalize_goal_value(raw)
for key, raw in value.items()
if clean_text(key, limit=80) and _normalize_goal_value(raw) not in ("", {}, [])
}
return out
if isinstance(value, list):
return [_normalize_goal_value(item) for item in value[:16] if _normalize_goal_value(item) not in ("", {}, [])]
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value
return clean_text(value, limit=500)
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[
"open_reasoning",
"credible_public_sources",
"official_preferred",
"official_only",
"internal_customer_safe",
] = "open_reasoning"
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)
answer_objective: dict[str, Any] = Field(default_factory=dict)
dynamic_rubric: list[dict[str, Any]] = Field(default_factory=list)
evidence_lanes: list[dict[str, Any]] = Field(default_factory=list)
disclosure_policy: dict[str, Any] = Field(default_factory=dict)
latency_policy: dict[str, Any] = Field(default_factory=dict)
deliverable_shape: dict[str, Any] = Field(default_factory=dict)
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 {
"open_reasoning",
"credible_public_sources",
"official_preferred",
"official_only",
"internal_customer_safe",
} else "open_reasoning"
@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("disclosure_policy", mode="before")
@classmethod
def _normalize_disclosure_policy(cls, value: Any) -> dict[str, Any]:
raw = value if isinstance(value, dict) else {}
visibility = clean_text(raw.get("visibility_class"), limit=80)
if visibility not in {
"public_product_capability",
"tenant_business_state",
"external_public",
"protected_pmflow_internal",
"platform_admin_debug",
}:
visibility = ""
response_mode = clean_text(raw.get("allowed_response_mode"), limit=80)
if response_mode not in {
"answer",
"safe_product_boundary",
"protected_refusal",
"platform_admin_redirect",
}:
response_mode = ""
categories = [
clean_text(item, limit=80)
for item in list(raw.get("protected_categories") or [])[:12]
if clean_text(item, limit=80)
]
contract_key = clean_text(raw.get("contract_key"), limit=120)
if visibility == "protected_pmflow_internal" or response_mode == "protected_refusal":
visibility = "protected_pmflow_internal"
response_mode = "protected_refusal"
contract_key = contract_key or "protected_internal_refusal.v1"
if not categories:
categories = ["pmflow_internal_implementation"]
return {
"visibility_class": visibility,
"protected_categories": categories,
"allowed_response_mode": response_mode,
"policy_reason": clean_text(raw.get("policy_reason"), limit=400),
"contract_key": contract_key,
}
@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",
"generated_by": clean_text(item.get("generated_by") or "", limit=80),
"provisional_assumption": clean_text(
item.get("provisional_assumption") or item.get("assumption") or "",
limit=360,
),
"answer_assumption_instruction": clean_text(
item.get("answer_assumption_instruction") or item.get("answer_instruction") or "",
limit=360,
),
"evidence_probe_queries": [
clean_text(query, limit=220)
for query in list(item.get("evidence_probe_queries") or item.get("probe_queries") or [])[:6]
if clean_text(query, limit=220)
],
"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"),
"generated_by": "",
"provisional_assumption": "",
"answer_assumption_instruction": "",
"evidence_probe_queries": [],
"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]
@field_validator("dynamic_rubric", "evidence_lanes", mode="before")
@classmethod
def _normalize_goal_dict_rows(cls, value: Any) -> list[dict[str, Any]]:
rows = value if isinstance(value, list) else []
out: list[dict[str, Any]] = []
for item in rows[:12]:
if not isinstance(item, dict):
text = clean_text(item, limit=360)
if text:
out.append({"criterion": text})
continue
normalized: dict[str, Any] = {}
for key, raw in item.items():
key_text = clean_text(key, limit=80)
if not key_text:
continue
if isinstance(raw, list):
normalized[key_text] = [clean_text(row, limit=240) for row in raw[:8] if clean_text(row, limit=240)]
elif isinstance(raw, dict):
normalized[key_text] = {
clean_text(k, limit=80): clean_text(v, limit=240)
for k, v in raw.items()
if clean_text(k, limit=80) and clean_text(v, limit=240)
}
else:
text = clean_text(raw, limit=360)
if text:
normalized[key_text] = text
if normalized:
out.append(normalized)
return out
@field_validator("answer_objective", "latency_policy", "deliverable_shape", mode="before")
@classmethod
def _normalize_goal_dict(cls, value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
out: dict[str, Any] = {}
for key, raw in value.items():
key_text = clean_text(key, limit=80)
if not key_text:
continue
if isinstance(raw, list):
out[key_text] = [_normalize_goal_value(item) for item in raw[:16] if _normalize_goal_value(item) not in ("", {}, [])]
elif isinstance(raw, dict):
nested = {
clean_text(k, limit=80): _normalize_goal_value(v)
for k, v in raw.items()
if clean_text(k, limit=80) and _normalize_goal_value(v) not in ("", {}, [])
}
if nested:
out[key_text] = nested
else:
text = clean_text(raw, limit=500)
if text:
out[key_text] = text
return out
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)
source_tool: str = Field(default="", max_length=120)
source_kind: str = Field(default="", max_length=120)
interpretation_branch_id: str | None = Field(default=None, max_length=80)
evidence_lane: str = Field(default="", max_length=160)
coverage_contribution: str = Field(default="", max_length=500)
time_match: Literal["exact", "recent", "stale", "unknown"] = "unknown"
target_match: Literal["direct", "partial", "off_target", "unknown"] = "unknown"
authority_level: Literal["primary", "official", "credible_secondary", "weak", "unknown"] = "unknown"
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",
"source_tool",
"source_kind",
"interpretation_branch_id",
"evidence_lane",
"coverage_contribution",
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("time_match", mode="before")
@classmethod
def _normalize_time_match(cls, value: Any) -> str:
text = clean_text(value, limit=40)
return text if text in {"exact", "recent", "stale", "unknown"} else "unknown"
@field_validator("target_match", mode="before")
@classmethod
def _normalize_target_match(cls, value: Any) -> str:
text = clean_text(value, limit=40)
return text if text in {"direct", "partial", "off_target", "unknown"} else "unknown"
@field_validator("authority_level", mode="before")
@classmethod
def _normalize_authority_level(cls, value: Any) -> str:
text = clean_text(value, limit=60)
return text if text in {"primary", "official", "credible_secondary", "weak", "unknown"} else "unknown"
@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)
reader_job: str = Field(default="", max_length=500)
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)
density: Literal["compact", "standard", "dense"] = "standard"
content_relationships: list[str] = Field(default_factory=list)
recommended_blocks: list[dict[str, Any]] = Field(default_factory=list)
block_order: list[str] = Field(default_factory=list)
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", "reader_job", "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(
"content_relationships",
"block_order",
"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)]
@field_validator("recommended_blocks", mode="before")
@classmethod
def _normalize_recommended_blocks(cls, value: Any) -> list[dict[str, Any]]:
rows = value if isinstance(value, list) else []
out: list[dict[str, Any]] = []
for item in rows[:12]:
if not isinstance(item, dict):
continue
block_type = clean_text(item.get("block_type") or item.get("type") or "", limit=80)
if not block_type:
continue
out.append(
{
"block_type": block_type,
"purpose": clean_text(item.get("purpose") or item.get("summary") or "", limit=300),
"source": clean_text(item.get("source") or "answer_design_plan", limit=80),
}
)
return out
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_objective: dict[str, Any] = Field(default_factory=dict)
dynamic_rubric: list[dict[str, Any]] = Field(default_factory=list)
deliverable_shape: dict[str, Any] = Field(default_factory=dict)
answer_intent_plan: PMFlowAIAgentAnswerIntentPlan = Field(default_factory=PMFlowAIAgentAnswerIntentPlan)
interpretation_decision: dict[str, Any] = Field(default_factory=dict)
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_composer_stream",
"llm_composer_hypothesis_lead_repair",
"llm_composer_readability_repair",
"llm_composer_stream_readability_repair",
"llm_reviser",
"terminal_fallback",
"terminal_repair",
] = "terminal_fallback"
quality_findings: list[str] = Field(default_factory=list)
@field_validator("draft_id", mode="before")
@classmethod
def _normalize_draft_text(cls, value: Any) -> str:
return clean_text(value, limit=6000)
@field_validator("answer_text", mode="before")
@classmethod
def _normalize_draft_answer_text(cls, value: Any) -> str:
return clean_markdown_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 PMFlowAIAgentDeliverableArtifact(BaseModel):
"""Customer-safe structured artifact projected from the goal-authored deliverable shape."""
model_config = ConfigDict(extra="ignore")
artifact_id: str = Field(default_factory=lambda: new_agent_id("artifact"), max_length=80)
artifact_kind: str = Field(default="structured_deliverable", max_length=120)
title: str = Field(default="", max_length=240)
summary: str = Field(default="", max_length=1200)
slots: 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)
confidence: str = Field(default="unknown", max_length=40)
@field_validator("artifact_id", "artifact_kind", "title", "summary", "confidence", mode="before")
@classmethod
def _normalize_artifact_text(cls, value: Any) -> str:
return clean_text(value, limit=1200)
@field_validator("source_fact_ids", "source_observation_ids", mode="before")
@classmethod
def _normalize_artifact_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)
artifacts: list[PMFlowAIAgentDeliverableArtifact] = 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", "confidence", mode="before")
@classmethod
def _normalize_deliverable_packet_text(cls, value: Any) -> str:
return clean_text(value, limit=6000)
@field_validator("answer_text", mode="before")
@classmethod
def _normalize_deliverable_answer_text(cls, value: Any) -> str:
return clean_markdown_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)
action_type: str = Field(default="navigate", max_length=80)
action_payload: dict[str, Any] = Field(default_factory=dict)
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",
"action_type",
"target_ref",
"target_title",
"href",
"reason",
"completion_criteria",
"safety",
"source_observation_id",
mode="before",
)
@classmethod
def _normalize_candidate_text(cls, value: Any, info: ValidationInfo) -> str | None:
if value is None:
return None
if info.field_name == "href":
return clean_text(value, limit=300)
if info.field_name in {"action_type", "safety"}:
return clean_text(value, limit=80)
if info.field_name in {"candidate_id", "source_observation_id"}:
return clean_text(value, limit=80)
if info.field_name == "target_ref":
return clean_text(value, limit=160)
if info.field_name in {"title", "target_title"}:
return clean_text(value, limit=240)
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)]
@field_validator("action_payload", mode="before")
@classmethod
def _normalize_candidate_payload(cls, value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
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],
*,
observations: list[PMFlowAIAgentObservation] | None = None,
event_log: list[PMFlowAIAgentRunEvent] | None = None,
total_duration_ms: int | None = None,
) -> dict[str, Any]:
observation_by_id = {
str(item.observation_id): item
for item in list(observations or [])
if str(getattr(item, "observation_id", "") or "").strip()
}
event_summaries = _customer_event_summaries(event_log or [])
process_steps: list[dict[str, Any]] = []
for step in steps:
observation = observation_by_id.get(str(step.observation_id or ""))
tool_transcript = _customer_tool_transcript(step=step, observation=observation)
evidence = _customer_evidence_transcript(observation)
process_steps.append(
{
"key": step.key,
"label": step.label,
"step_type": step.step_type,
"selected_tool": step.selected_tool,
"status": step.status,
"display_summary": step.display_summary,
"duration_ms": step.duration_ms,
"blocking": step.blocking,
"observation_id": step.observation_id,
"process_narrative": _customer_process_narrative(step=step, observation=observation),
"activity_summary": _customer_activity_summary(step=step, observation=observation),
"tool_transcript": tool_transcript,
"evidence": evidence,
"boundary_summary": _customer_boundary_summary(observation),
}
)
return {
"packet_version": "customer_answer_process.v2",
"visibility": "customer",
"answer_type": "agent_run",
"route_display": "agent_runtime",
"steps": process_steps,
"event_summaries": event_summaries,
"total_duration_ms": max(0, int(total_duration_ms or 0)) if total_duration_ms is not None else None,
}
def _customer_event_summaries(events: list[PMFlowAIAgentRunEvent]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for event in events[:24]:
summary = clean_text(event.summary, limit=260)
if not summary:
continue
out.append(
{
"event_type": event.event_type,
"summary": summary,
"duration_ms": event.duration_ms,
"state_changed": bool(event.state_changed),
}
)
return out
def _customer_process_narrative(*, step: PMFlowAIAgentStep, observation: PMFlowAIAgentObservation | None) -> str:
values = [
step.display_summary,
observation.summary if observation is not None else "",
]
for value in values:
text = _customer_visible_text(value, limit=360)
if text:
return text
return ""
def _customer_activity_summary(*, step: PMFlowAIAgentStep, observation: PMFlowAIAgentObservation | None) -> str:
if observation is None:
return _customer_visible_text(step.display_summary, limit=240)
fact_count = len([item for item in list(observation.facts or []) if isinstance(item, dict) and _customer_fact_visible(item)])
source_count = len([item for item in list(observation.sources or []) if isinstance(item, dict) and _safe_url(item.get("url") or item.get("source_url"))])
parts: list[str] = []
if source_count:
parts.append(f"记录了 {source_count} 个可追溯来源")
if fact_count:
parts.append(f"整理了 {fact_count} 条可见观察")
summary = _customer_visible_text(observation.summary, limit=220)
if summary:
parts.append(summary)
return ";".join(parts[:3])
def _customer_tool_transcript(*, step: PMFlowAIAgentStep, observation: PMFlowAIAgentObservation | None) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if step.selected_tool:
out.append(
{
"kind": "capability",
"title": "调用能力",
"summary": _customer_capability_label(step.selected_tool),
}
)
if observation is None:
return out
for fact in list(observation.facts or [])[:32]:
if not isinstance(fact, dict):
continue
fact_type = clean_text(fact.get("type"), limit=80)
if fact_type == "external_search_query":
query = _customer_visible_text(fact.get("query"), limit=240)
queries = [
_customer_visible_text(item, limit=180)
for item in list(fact.get("queries") or [])
if _customer_visible_text(item, limit=180)
][:6]
summary = ";".join(queries) if queries else query
if summary:
out.append({"kind": "search_query", "title": "搜索", "summary": summary})
continue
if fact_type == "external_retrieval_status":
count = fact.get("evidence_count")
status = _customer_visible_text(fact.get("status"), limit=80)
summary = f"返回 {count} 条候选来源" if isinstance(count, int) else status
if status and isinstance(count, int):
summary = f"{summary},检索状态:{status}"
if summary:
out.append({"kind": "tool_result", "title": "检索结果", "summary": summary})
continue
if fact_type == "external_source_unreadable":
url = _safe_url(fact.get("url"))
summary = _customer_visible_text(fact.get("summary"), limit=220) or "来源不可读,未进入最终判断。"
out.append({"kind": "unavailable_source", "title": "来源不可读", "summary": summary, "url": url})
continue
if fact_type == "visible_object_count":
value = fact.get("value")
if value is not None:
out.append({"kind": "space_read", "title": "读取当前空间", "summary": f"读取到 {value} 项可见内容。"})
continue
return _dedupe_process_items(out)[:16]
def _customer_evidence_transcript(observation: PMFlowAIAgentObservation | None) -> list[dict[str, Any]]:
if observation is None:
return []
out: list[dict[str, Any]] = []
for source in list(observation.sources or [])[:12]:
if not isinstance(source, dict):
continue
url = _safe_url(source.get("url") or source.get("source_url"))
title = _customer_source_display_title(url=url, title=source.get("label") or source.get("title"), summary=source.get("summary") or source.get("snippet"))
summary = _customer_visible_text(source.get("summary") or source.get("snippet"), limit=260)
if url or title or summary:
out.append(
{
"kind": "source",
"title": title or _domain_label(url),
"summary": summary,
"url": url,
"domain": _domain_label(url),
}
)
for fact in list(observation.facts or [])[:32]:
if not isinstance(fact, dict) or not _customer_fact_visible(fact):
continue
fact_type = clean_text(fact.get("type"), limit=80)
if fact_type == "external_evidence":
url = _safe_url(fact.get("url") or fact.get("source_url"))
summary = _customer_visible_text(fact.get("summary") or fact.get("snippet"), limit=300)
title = _customer_source_display_title(url=url, title=fact.get("title"), summary=summary)
out.append({"kind": "search_result", "title": title, "summary": summary, "url": url, "domain": _domain_label(url)})
continue
if fact_type == "external_source_text":
url = _safe_url(fact.get("url") or fact.get("source_url"))
summary = _customer_source_excerpt(fact.get("text") or fact.get("summary"))
title = _customer_source_display_title(url=url, title=fact.get("title"), summary=summary)
out.append({"kind": "opened_source", "title": title, "summary": summary, "url": url, "domain": _domain_label(url)})
continue
if fact_type == "visible_object_samples":
items = []
for item in list(fact.get("items") or [])[:8]:
if not isinstance(item, dict):
continue
title = _customer_visible_text(item.get("title"), limit=120)
kind = _customer_visible_text(item.get("kind"), limit=40)
if title:
items.append({"title": title, "kind": kind})
if items:
out.append({"kind": "space_objects", "title": "当前空间可见内容", "items": items})
continue
return _dedupe_process_items(out)[:20]
def _customer_boundary_summary(observation: PMFlowAIAgentObservation | None) -> str:
if observation is None:
return ""
gaps: list[str] = []
for fact in list(observation.facts or [])[:32]:
if not isinstance(fact, dict):
continue
if clean_text(fact.get("type"), limit=80) in {"external_source_unreadable", "missing_external_query"}:
text = _customer_visible_text(fact.get("summary") or fact.get("url"), limit=220)
if text:
gaps.append(text)
for gap in list(getattr(observation, "gaps", []) or [])[:8]:
if isinstance(gap, dict):
text = _customer_visible_text(gap.get("summary") or gap.get("description"), limit=220)
else:
text = _customer_visible_text(gap, limit=220)
if text:
gaps.append(text)
return ";".join(_dedupe_text(gaps)[:3])
def _customer_fact_visible(fact: dict[str, Any]) -> bool:
fact_type = clean_text(fact.get("type"), limit=80)
if not fact_type or fact_type in {"agent_tool_contract"}:
return False
if fact_type.startswith("_"):
return False
return True
def _customer_visible_text(value: Any, *, limit: int = 300) -> str:
text = clean_text(value, limit=limit)
if not text:
return ""
lowered = text.lower()
blocked = (
"prompt",
"trace",
"provider",
"runtime",
"goal_frame",
"turn_state",
"evidence normalizer",
"evidencenormalizer",
"statecritic",
"function",
)
if any(token in lowered for token in blocked):
return ""
return text
def _customer_source_excerpt(value: Any) -> str:
text = _customer_visible_text(value, limit=700)
if not text:
return ""
for marker in (" Skip to content ", " Agree & Join ", " Your browser is not supported. "):
if marker in text:
text = text.split(marker, 1)[0].strip() or text.split(marker, 1)[-1].strip()
if len(text) <= 260:
return text
cut = text[:260].rstrip()
boundary = max(cut.rfind("。"), cut.rfind("."), cut.rfind(";"), cut.rfind(";"), cut.rfind(","), cut.rfind(","))
if boundary >= 80:
cut = cut[: boundary + 1].rstrip()
return f"{cut}..."
def _customer_source_display_title(*, url: Any, title: Any = "", summary: Any = "") -> str:
safe_title = _customer_visible_text(title, limit=180)
safe_url = _safe_url(url)
if safe_title and not safe_title.startswith(("http://", "https://")):
return safe_title
safe_summary = _customer_visible_text(summary, limit=180)
if safe_summary and not safe_summary.startswith(("http://", "https://")):
first = re.split(r"[。.!?!?]\s*", safe_summary, maxsplit=1)[0].strip()
if 8 <= len(first) <= 120:
return first
slug_title = _title_from_url_path(safe_url)
if slug_title:
return slug_title
return _domain_label(safe_url) or "公开来源"
def _title_from_url_path(url: str) -> str:
if not url:
return ""
try:
parsed = urlparse(url)
except Exception:
return ""
path_parts = [part for part in parsed.path.split("/") if part]
if not path_parts:
return ""
candidate = unquote(path_parts[-1]).strip()
candidate = re.sub(r"\.(html?|php|aspx?)$", "", candidate, flags=re.IGNORECASE)
candidate = re.sub(r"[-_]+", " ", candidate)
candidate = re.sub(r"\s+", " ", candidate).strip(" -_/")
candidate = re.sub(r"\b[a-z0-9]{5,}$", "", candidate, flags=re.IGNORECASE).strip()
if not candidate:
return ""
if len(candidate) > 120:
candidate = candidate[:120].rsplit(" ", 1)[0].strip() or candidate[:120].strip()
return candidate
def _customer_capability_label(value: Any) -> str:
text = clean_text(value, limit=120).replace("_", " ").strip()
return text or "只读能力"
def _safe_url(value: Any) -> str:
text = clean_text(value, limit=500)
return text if text.startswith(("http://", "https://")) else ""
def _domain_label(url: str) -> str:
if not url:
return ""
try:
return urlparse(url).netloc.replace("www.", "")
except Exception:
return ""
def _dedupe_text(rows: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for row in rows:
text = clean_text(row, limit=300)
key = text.lower()
if not text or key in seen:
continue
seen.add(key)
out.append(text)
return out
def _dedupe_process_items(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str] = set()
out: list[dict[str, Any]] = []
for row in rows:
key = _process_item_dedupe_key(row)
if not key.strip("|") or key in seen:
continue
seen.add(key)
out.append(row)
return out
def _process_item_dedupe_key(row: dict[str, Any]) -> str:
url_key = _normalized_source_url_key(row.get("url"))
if url_key:
return f"url:{url_key}"
items = row.get("items")
if isinstance(items, list) and items:
item_key = "|".join(
clean_text((item or {}).get("title"), limit=120).lower()
for item in items
if isinstance(item, dict) and clean_text(item.get("title"), limit=120)
)
if item_key:
return f"items:{item_key}"
return "|".join(str(row.get(name) or "") for name in ("kind", "title", "summary")).lower()
def _normalized_source_url_key(value: Any) -> str:
url = _safe_url(value)
if not url:
return ""
try:
parsed = urlparse(url)
except Exception:
return url.lower().rstrip("/")
path = re.sub(r"/+$", "", parsed.path or "/")
return f"{parsed.netloc.lower().replace('www.', '')}{path.lower()}"
__all__ = [
"PMFlowAIAgentAnswerDraft",
"PMFlowAIAgentAnswerIntentPlan",
"PMFlowAIAgentAnswerRuntimePacket",
"PMFlowAIAgentAttempt",
"PMFlowAIAgentBoundary",
"PMFlowAIAgentClaim",
"PMFlowAIAgentCriticResult",
"PMFlowAIAgentEvidenceNormalization",
"PMFlowAIAgentGoalFrame",
"PMFlowAIAgentActionLedgerEntry",
"PMFlowAIAgentActionCandidateLedgerEntry",
"PMFlowAIAgentAffordanceLedgerEntry",
"PMFlowAIAgentAttemptLedgerEntry",
"PMFlowAIAgentDeliverablePlan",
"PMFlowAIAgentDeliverablePacket",
"PMFlowAIAgentDeliverableArtifact",
"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",
]