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