"""Autonomy gate and prepared action compiler for operating runs.""" from __future__ import annotations from typing import Any from ...schemas.pmflow_ai_operating_runtime import ( PMFlowAIAgentCouncilReview, PMFlowAIAgentWorkProduct, PMFlowAIAutonomyDecision, PMFlowAIConfirmationRequest, PMFlowAIExecutionPreparation, PMFlowAIOperatingMission, PMFlowAIOperatingPlan, PMFlowAIPreparedActionPacket, PMFlowAIProjectWorldStatePacket, clean_text, ) PROHIBITED_SIDE_EFFECTS = [ "write_project_memory", "commit_changeset", "create_or_update_task", "execute_handoff", "publish_release", "call_external_system", ] class PMFlowAIAutonomyPolicyService: """Apply autonomy boundaries after council review. This service consumes action contracts produced by the runtime. It does not infer user intent from wording, prompt keys or business keywords. """ def apply( self, *, mission: PMFlowAIOperatingMission, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], council_review: PMFlowAIAgentCouncilReview, operating_plan: PMFlowAIOperatingPlan, ) -> tuple[PMFlowAIAutonomyDecision, PMFlowAIOperatingPlan]: prepared_service = PreparedChangeService() confirmation_gate = ConfirmationGate() execution_service = ExecutionPreparationService() prepared_packets = prepared_service.prepare_packets( mission=mission, project_world_state=project_world_state, work_products=work_products, operating_plan=operating_plan, ) execution_preparations = execution_service.prepare_handoff_packages( mission=mission, project_world_state=project_world_state, work_products=work_products, operating_plan=operating_plan, ) confirmation_requests = confirmation_gate.build_requests( mission=mission, prepared_packets=prepared_packets, execution_preparations=execution_preparations, ) prepared_packets = confirmation_gate.attach_request_ids( prepared_packets=prepared_packets, confirmation_requests=confirmation_requests, ) execution_preparations = confirmation_gate.attach_execution_request_ids( execution_preparations=execution_preparations, confirmation_requests=confirmation_requests, ) blocked_actions = _blocked_actions( operating_plan=operating_plan, council_review=council_review, mission=mission, ) safe_suggestions = _safe_suggestions(operating_plan=operating_plan, blocked_actions=blocked_actions) policy_findings = _policy_findings( mission=mission, prepared_packets=prepared_packets, confirmation_requests=confirmation_requests, execution_preparations=execution_preparations, blocked_actions=blocked_actions, ) status = "suggest_only" if blocked_actions and not prepared_packets and not execution_preparations: status = "blocked" elif prepared_packets or execution_preparations or confirmation_requests: status = "prepared_waiting_confirmation" autonomy_gate = PMFlowAIAutonomyDecision( status=status, autonomy_level=mission.autonomy_level, safe_suggestions=safe_suggestions, prepared_action_packets=[item.model_dump(mode="json", exclude_none=True) for item in prepared_packets], confirmation_requests=[item.model_dump(mode="json", exclude_none=True) for item in confirmation_requests], execution_preparations=[item.model_dump(mode="json", exclude_none=True) for item in execution_preparations], blocked_actions=blocked_actions, policy_findings=policy_findings, prohibited_side_effects=PROHIBITED_SIDE_EFFECTS, ) updated_plan = operating_plan.model_copy( update={ "safe_suggestions": safe_suggestions, "prepared_changes": [item.model_dump(mode="json", exclude_none=True) for item in prepared_packets], "requires_confirmation": [item.model_dump(mode="json", exclude_none=True) for item in confirmation_requests], "blocked_actions": blocked_actions, "autonomy_gate": autonomy_gate.model_dump(mode="json", exclude_none=True), "prepared_action_packets": [item.model_dump(mode="json", exclude_none=True) for item in prepared_packets], "confirmation_requests": [item.model_dump(mode="json", exclude_none=True) for item in confirmation_requests], "execution_preparations": [item.model_dump(mode="json", exclude_none=True) for item in execution_preparations], } ) return autonomy_gate, updated_plan class PreparedChangeService: """Build customer-safe prepared action packets without writing state.""" def prepare_packets( self, *, mission: PMFlowAIOperatingMission, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], operating_plan: PMFlowAIOperatingPlan, ) -> list[PMFlowAIPreparedActionPacket]: product_by_agent = {item.agent_id: item for item in work_products} packets: list[PMFlowAIPreparedActionPacket] = [] for action in list(operating_plan.prepared_changes or [])[:12]: if not isinstance(action, dict): continue action_type = clean_text(action.get("action_type"), limit=120) source_agent = clean_text(action.get("source_agent"), limit=80) family = _family_for_action_type(action_type) if not family: continue packet = PMFlowAIPreparedActionPacket( action_family=family, title=clean_text(action.get("title"), limit=240), target_ref=clean_text(action.get("target_ref") or project_world_state.primary_project_ref, limit=160), source_agent=source_agent, source_action_type=action_type, payload=_prepared_payload( action=action, family=family, mission=mission, project_world_state=project_world_state, source_product=product_by_agent.get(source_agent), ), evidence_refs=_evidence_refs_for_action(action=action, source_product=product_by_agent.get(source_agent)), confirmation_required=True, status="prepared_waiting_confirmation", prohibited_side_effects=PROHIBITED_SIDE_EFFECTS, ) if packet.title: packets.append(packet) return _dedupe_packets(packets) class ConfirmationGate: """Create confirmation requests for every prepared write or handoff.""" def build_requests( self, *, mission: PMFlowAIOperatingMission, prepared_packets: list[PMFlowAIPreparedActionPacket], execution_preparations: list[PMFlowAIExecutionPreparation], ) -> list[PMFlowAIConfirmationRequest]: requests: list[PMFlowAIConfirmationRequest] = [] for packet in prepared_packets: requests.append( PMFlowAIConfirmationRequest( title=packet.title, target_ref=packet.target_ref, requested_for=[packet.prepared_action_id], confirmation_reason=_confirmation_reason_for_family(packet.action_family), required_before=_required_before_for_family(packet.action_family), action_label=_action_label_for_family(packet.action_family), action_type=_confirmation_action_type(packet.action_family), action_payload=_confirmation_action_payload(packet), can_execute_after_confirmation=packet.action_family in {"research_recovery", "product_object_projection"}, status="waiting_for_user_confirmation", ) ) for item in execution_preparations: can_execute = item.readiness == "prepared_waiting_confirmation" requests.append( PMFlowAIConfirmationRequest( title="执行交接", target_ref=item.target_ref, requested_for=[item.execution_preparation_id], confirmation_reason="执行 handoff 会改变项目执行态,只能在用户确认后由执行链路继续处理。", required_before=["execute_handoff", "create_or_update_task", "write_project_memory"], action_label="创建执行交接" if can_execute else "查看缺口", action_type="start_execution_handoff" if can_execute else "review_execution_blockers", action_payload=_execution_confirmation_action_payload(item), can_execute_after_confirmation=can_execute, status="waiting_for_user_confirmation", ) ) if not requests and mission.autonomy_level in {"execute_after_confirmation", "autonomous_cycle_within_policy"}: requests.append( PMFlowAIConfirmationRequest( title="确认本轮 Operating Plan 的下一步边界", target_ref=clean_text(mission.project_scope.object_refs[0] if mission.project_scope.object_refs else "", limit=160), requested_for=["operating_plan"], confirmation_reason="当前没有可准备写入包,但任何后续写入、执行、发布或外部动作仍必须先确认。", required_before=PROHIBITED_SIDE_EFFECTS, can_execute_after_confirmation=False, status="waiting_for_user_confirmation", ) ) return requests[:16] def attach_request_ids( self, *, prepared_packets: list[PMFlowAIPreparedActionPacket], confirmation_requests: list[PMFlowAIConfirmationRequest], ) -> list[PMFlowAIPreparedActionPacket]: request_by_item = { requested_for: request.confirmation_request_id for request in confirmation_requests for requested_for in request.requested_for } return [ item.model_copy(update={"confirmation_request_id": request_by_item.get(item.prepared_action_id)}) for item in prepared_packets ] def attach_execution_request_ids( self, *, execution_preparations: list[PMFlowAIExecutionPreparation], confirmation_requests: list[PMFlowAIConfirmationRequest], ) -> list[PMFlowAIExecutionPreparation]: request_by_item = { requested_for: request.confirmation_request_id for request in confirmation_requests for requested_for in request.requested_for } return [ item.model_copy(update={"required_confirmations": [request_by_item[item.execution_preparation_id]]}) if item.execution_preparation_id in request_by_item else item for item in execution_preparations ] class ExecutionPreparationService: """Prepare an execution handoff package without starting execution.""" def prepare_handoff_packages( self, *, mission: PMFlowAIOperatingMission, project_world_state: PMFlowAIProjectWorldStatePacket, work_products: list[PMFlowAIAgentWorkProduct], operating_plan: PMFlowAIOperatingPlan, ) -> list[PMFlowAIExecutionPreparation]: execution_product = _product_for_agent(work_products=work_products, agent_id="pm_breakdown_agent") technical_product = _product_for_agent(work_products=work_products, agent_id="tech_architect_agent") if execution_product is None and technical_product is None: return [] target_ref = project_world_state.primary_project_ref or ( mission.project_scope.object_refs[0] if mission.project_scope.object_refs else "" ) task_candidates = [ item for item in list(operating_plan.priority_order or [])[:5] if isinstance(item, dict) and clean_text(item.get("title"), limit=240) ] readiness = "prepared_waiting_confirmation" if task_candidates and project_world_state.status in {"ready", "partial"} else "blocked_by_missing_context" blocked_actions: list[dict[str, Any]] = [] if readiness != "prepared_waiting_confirmation": blocked_actions.append( { "title": "发起执行 handoff", "reason": "执行交接需要项目世界状态、任务候选和技术准备度证据。", "source": "execution_preparation_gate", } ) return [ PMFlowAIExecutionPreparation( target_ref=target_ref, readiness=readiness, handoff_package={ "packet_version": "execution_bridge_handoff_preparation.v1", "mission_id": mission.mission_id, "target_ref": target_ref, "task_candidates": task_candidates, "technical_readiness": _structured_output(technical_product).get("handoff_readiness"), "dependency_gaps": _structured_output(execution_product).get("dependency_gaps", []), "execution_capability_graph": { "supported_runtimes": ["codex", "cursor", "github_agent", "local_bridge"], "required_inputs": ["confirmed_task", "project_memory_snapshot", "acceptance_criteria", "repo_binding"], "result_events": ["handoff_created", "execution_package_pulled", "execution_result_reported", "user_acceptance_recorded"], }, "prompt_package_contract": { "status": "prepared_contract_only", "required_sections": ["mission", "scope", "files_or_modules", "acceptance_tests", "writeback_protocol"], "forbidden": ["execute_local_command_without_user_confirmation", "write_project_memory_before_acceptance"], }, "result_writeback_contract": { "protocol_version": "execution_result_protocol_v1", "expected_fields": ["execution_status", "changed_files", "commands_run", "test_outputs", "blocker_summary"], "memory_writeback_policy": "after_user_acceptance_or_review_gate", }, "evidence_refs": _dedupe_texts( list(execution_product.evidence_refs if execution_product is not None else []) + list(technical_product.evidence_refs if technical_product is not None else []) ), "execution_boundary": "prepared_package_only_no_handoff_started", }, required_confirmations=[], blocked_actions=blocked_actions, prohibited_side_effects=PROHIBITED_SIDE_EFFECTS, ) ] def _family_for_action_type(action_type: str) -> str: handlers = { "prepare_task_change": "task_change", "prepare_validation_experiment": "experiment_plan", "prepare_requirement_changes": "project_memory_candidate", "prepare_product_object": "product_object_projection", "continue_research": "research_recovery", "prepare_from_project_world_state": "task_change", } return handlers.get(clean_text(action_type, limit=120), "") def _prepared_payload( *, action: dict[str, Any], family: str, mission: PMFlowAIOperatingMission, project_world_state: PMFlowAIProjectWorldStatePacket, source_product: PMFlowAIAgentWorkProduct | None, ) -> dict[str, Any]: structured = _structured_output(source_product) base = { "mission_id": mission.mission_id, "target_ref": clean_text(action.get("target_ref") or project_world_state.primary_project_ref, limit=160), "title": clean_text(action.get("title"), limit=240), "source_product_type": clean_text(source_product.product_type if source_product is not None else "", limit=120), "prepared_only": True, } if family == "task_change": return { **base, "task_change_candidate": { "title": base["title"], "acceptance_boundary": "must_be_confirmed_before_task_write", "priority_order_ref": clean_text(action.get("priority_order_ref"), limit=80), "dependency_gaps": structured.get("dependency_gaps", []), }, } if family == "experiment_plan": return { **base, "experiment_plan": { "hypotheses": structured.get("critical_hypotheses", []), "experiments": structured.get("experiments", []), "validation_gaps": structured.get("validation_gaps", []), }, } if family == "project_memory_candidate": return { **base, "project_memory_candidate": { "mvp_scope_read": structured.get("mvp_scope_read", ""), "in_scope_candidates": structured.get("in_scope_candidates", []), "out_of_scope_now": structured.get("out_of_scope_now", []), "write_boundary": "candidate_only_until_user_confirms_project_memory_write", }, } if family == "product_object_projection": object_type = "OperatingObject" if source_product is not None: object_type = clean_text( structured.get("product_object_type") or structured.get("object_type") or source_product.product_type, limit=120, ) or "OperatingObject" return { **base, "product_object_projection": { "object_type": object_type, "fields": structured, "write_boundary": "candidate_only_until_user_confirms_product_object_projection", }, } if family == "research_recovery": return { **base, "research_recovery": { "research_question": structured.get("research_question") or mission.original_user_input, "source_plan": structured.get("source_plan", []), "research_gaps": structured.get("research_gaps", []), "market_claims": structured.get("market_claims", []), "queries": action.get("research_queries") or [item.get("query") for item in structured.get("source_plan", []) if isinstance(item, dict)], "write_boundary": "read_only_research_until_user_confirms_product_object_projection", }, } return base def _confirmation_reason_for_family(action_family: str) -> str: reasons = { "task_change": "任务变更会影响执行队列,确认前只能作为候选准备包。", "experiment_plan": "用户验证实验会改变验证节奏和后续判断,确认前不能写入项目事实。", "project_memory_candidate": "Project Memory 候选会改变项目真源,必须确认后才能写入。", "product_object_projection": "经营对象候选会进入想法、研究或项目推进链路,确认前只能作为草案展示。", "research_recovery": "当前证据还不足以生成机会结论,确认后只继续做只读研究补证,不写项目记忆或创建任务。", "execution_handoff_package": "执行交接会触发开发/执行链路,必须确认后才能进入 handoff。", } return reasons.get(action_family, "该动作可能改变项目状态,必须确认后才能继续。") def _required_before_for_family(action_family: str) -> list[str]: required = { "task_change": ["create_or_update_task", "commit_changeset", "write_project_memory"], "experiment_plan": ["write_project_memory", "call_external_system"], "project_memory_candidate": ["write_project_memory", "commit_changeset"], "product_object_projection": ["write_project_memory", "commit_changeset", "create_or_update_task"], "research_recovery": ["write_project_memory", "commit_changeset", "create_or_update_task", "execute_handoff"], "execution_handoff_package": ["execute_handoff", "create_or_update_task", "write_project_memory"], } return required.get(action_family, PROHIBITED_SIDE_EFFECTS) def _action_label_for_family(action_family: str) -> str: labels = { "research_recovery": "继续补研究", "product_object_projection": "保存草案", "task_change": "准备任务变更", "experiment_plan": "准备验证实验", "project_memory_candidate": "准备记忆候选", "execution_handoff_package": "准备执行交接", } return labels.get(action_family, "确认") def _confirmation_action_type(action_family: str) -> str: if action_family == "research_recovery": return "continue_research" if action_family == "product_object_projection": return "save_candidate" if action_family == "execution_handoff_package": return "start_execution_handoff" return "review_prepared_action" def _confirmation_action_payload(packet: PMFlowAIPreparedActionPacket) -> dict[str, Any]: payload = packet.payload if isinstance(packet.payload, dict) else {} return { "prepared_action_id": packet.prepared_action_id, "action_family": packet.action_family, "target_ref": packet.target_ref, "title": packet.title, "payload": payload, } def _execution_confirmation_action_payload(item: PMFlowAIExecutionPreparation) -> dict[str, Any]: return { "execution_preparation_id": item.execution_preparation_id, "action_family": "execution_handoff_package", "target_ref": item.target_ref, "title": "执行交接", "readiness": item.readiness, "payload": { "handoff_package": item.handoff_package if isinstance(item.handoff_package, dict) else {}, "blocked_actions": list(item.blocked_actions or []), "prohibited_side_effects": list(item.prohibited_side_effects or []), }, } def _blocked_actions( *, operating_plan: PMFlowAIOperatingPlan, council_review: PMFlowAIAgentCouncilReview, mission: PMFlowAIOperatingMission, ) -> list[dict[str, Any]]: rows = [dict(item) for item in list(operating_plan.blocked_actions or []) if isinstance(item, dict)] if mission.autonomy_level in {"answer_only", "read_and_diagnose", "propose_plan"}: rows.append( { "title": "准备或执行项目变更", "reason": f"本轮 autonomy_level={mission.autonomy_level},只能建议或诊断,不能准备写入/执行包。", "source": "autonomy_policy", "requires_user_confirm": True, } ) for veto in list(council_review.risk_vetoes or []): if isinstance(veto, dict): rows.append({**veto, "source": "agent_council_risk_veto", "council_status": "blocked"}) return _dedupe_dicts(rows, key="title")[:16] def _safe_suggestions(*, operating_plan: PMFlowAIOperatingPlan, blocked_actions: list[dict[str, Any]]) -> list[dict[str, Any]]: blocked_titles = {clean_text(item.get("title"), limit=240).lower() for item in blocked_actions} suggestions: list[dict[str, Any]] = [] for item in list(operating_plan.safe_suggestions or operating_plan.priority_order or [])[:12]: if not isinstance(item, dict): continue title = clean_text(item.get("title"), limit=240) if not title or title.lower() in blocked_titles: continue suggestions.append( { **item, "title": title, "autonomy_status": "safe_suggestion", "confirmation_required": False, "allowed_next_step": "show_to_user", } ) return _dedupe_dicts(suggestions, key="title")[:8] def _policy_findings( *, mission: PMFlowAIOperatingMission, prepared_packets: list[PMFlowAIPreparedActionPacket], confirmation_requests: list[PMFlowAIConfirmationRequest], execution_preparations: list[PMFlowAIExecutionPreparation], blocked_actions: list[dict[str, Any]], ) -> list[str]: findings = [ "所有 Project Memory 写入、ChangeSet commit、任务变更、执行 handoff、发布和外部动作均被 Autonomy Gate 阻断到确认之后。", ] if prepared_packets: findings.append(f"已准备 {len(prepared_packets)} 个候选动作包,全部等待用户确认。") if execution_preparations: findings.append(f"已准备 {len(execution_preparations)} 个执行交接包,但未发起 handoff。") if confirmation_requests: findings.append(f"已生成 {len(confirmation_requests)} 个确认请求。") if blocked_actions: findings.append(f"仍有 {len(blocked_actions)} 个动作被策略或风险阻断。") findings.append(f"本轮 autonomy_level={mission.autonomy_level}。") return findings[:8] def _evidence_refs_for_action(*, action: dict[str, Any], source_product: PMFlowAIAgentWorkProduct | None) -> list[str]: refs = [clean_text(item, limit=180) for item in list(action.get("evidence_refs") or []) if clean_text(item, limit=180)] if not refs and source_product is not None: refs = list(source_product.evidence_refs or []) return _dedupe_texts(refs)[:12] def _structured_output(product: PMFlowAIAgentWorkProduct | None) -> dict[str, Any]: return dict(product.structured_output or {}) if product is not None and isinstance(product.structured_output, dict) else {} def _product_for_agent(*, work_products: list[PMFlowAIAgentWorkProduct], agent_id: str) -> PMFlowAIAgentWorkProduct | None: return next((item for item in work_products if item.agent_id == agent_id), None) def _dedupe_packets(rows: list[PMFlowAIPreparedActionPacket]) -> list[PMFlowAIPreparedActionPacket]: seen: set[str] = set() out: list[PMFlowAIPreparedActionPacket] = [] for item in rows: key = f"{item.action_family}:{item.title}".lower() if not item.title or key in seen: continue seen.add(key) out.append(item) return out[:12] def _dedupe_dicts(rows: list[dict[str, Any]], *, key: str) -> list[dict[str, Any]]: seen: set[str] = set() out: list[dict[str, Any]] = [] for item in rows: if not isinstance(item, dict): continue value = clean_text(item.get(key), limit=240) normalized = value.lower() if not value or normalized in seen: continue seen.add(normalized) out.append(item) return out def _dedupe_texts(rows: list[str]) -> list[str]: out: list[str] = [] for item in rows: text = clean_text(item, limit=220) if text and text not in out: out.append(text) return out __all__ = [ "ConfirmationGate", "ExecutionPreparationService", "PMFlowAIAutonomyPolicyService", "PreparedChangeService", ]