"""Legacy deterministic loop for PMFlow Agent OS compatibility. The user-facing `/space/pmflow-ai` main path is `PMFlowAIAgentRunService` through `PMFlowAgentRuntimeService`; this module must not be restored as the product controller. It remains available for older environment/specialist tests and compatibility projections only. """ from __future__ import annotations import time from dataclasses import dataclass, field from typing import Any from ...schemas.pmflow_agent_runtime import ( PMFlowAgentArtifactPacket, PMFlowAgentMemoryPacket, PMFlowAgentObservationPacket, PMFlowAgentStepPacket, PMFlowAgentVerificationPacket, new_pmflow_agent_id, ) from ...schemas.pmflow_ai_agent_runtime import PMFlowAIAgentObservation, PMFlowAIAgentStepProposal from ...schemas.rag import RAGAskRequest from ..pmflow_ai_agent_runtime.agent_tool_registry import PMFlowAIAgentToolRegistry from .specialists import PMFlowAgentSpecialistResult, PMFlowAgentSpecialistRuntimeService @dataclass(slots=True) class PMFlowAgentWorkspaceBinding: scope_kind: str = "space" binding_strength: str = "space" goal_scope: str = "space_operating" object_ref: str | None = None project_id: int | None = None candidates: list[dict[str, Any]] = field(default_factory=list) facts: list[dict[str, Any]] = field(default_factory=list) gaps: list[dict[str, Any]] = field(default_factory=list) @dataclass(slots=True) class PMFlowAgentStepPlan: step_id: str tool_name: str label: str summary: str tool_input: dict[str, Any] = field(default_factory=dict) risk_level: str = "low" @dataclass(slots=True) class PMFlowAgentStepExecution: plan: PMFlowAgentStepPlan step: PMFlowAgentStepPacket observations: list[PMFlowAgentObservationPacket] = field(default_factory=list) artifacts: list[PMFlowAgentArtifactPacket] = field(default_factory=list) verifications: list[PMFlowAgentVerificationPacket] = field(default_factory=list) memories: list[PMFlowAgentMemoryPacket] = field(default_factory=list) payload_delta: dict[str, Any] = field(default_factory=dict) @dataclass(slots=True) class PMFlowAgentLoopResult: binding: PMFlowAgentWorkspaceBinding steps: list[PMFlowAgentStepPacket] = field(default_factory=list) observations: list[PMFlowAgentObservationPacket] = field(default_factory=list) artifacts: list[PMFlowAgentArtifactPacket] = field(default_factory=list) verifications: list[PMFlowAgentVerificationPacket] = field(default_factory=list) memories: list[PMFlowAgentMemoryPacket] = field(default_factory=list) specialist_result: PMFlowAgentSpecialistResult = field(default_factory=PMFlowAgentSpecialistResult) command_payload: dict[str, Any] = field(default_factory=dict) answer_inputs: dict[str, Any] = field(default_factory=dict) status: str = "completed" class PMFlowAgentWorkspaceBinder: """Bind a turn to the safest structured workspace/object scope. This class intentionally does not parse natural-language keywords. Binding only comes from explicit request fields, stored conversation context or a structured surface focus object. Visible objects remain candidates. """ def bind( self, *, request: RAGAskRequest, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> PMFlowAgentWorkspaceBinding: project_ref = _strong_project_ref( request=request, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) candidates = _visible_project_candidates(tenant_project_context) facts = [ {"type": "user_goal", "summary": _text(getattr(request, "question", ""), 500)}, {"type": "visible_project_candidate_count", "count": len(candidates)}, ] goal_scope = _goal_scope_from_question(getattr(request, "question", "")) if project_ref and goal_scope != "capability": goal_scope = "project_operating" if project_ref: return PMFlowAgentWorkspaceBinding( scope_kind="project" if goal_scope == "project_operating" else "space", binding_strength="structured_anchor", goal_scope=goal_scope, object_ref=project_ref, project_id=_project_id_from_ref(project_ref), candidates=candidates, facts=[*facts, {"type": "bound_project", "object_ref": project_ref}], ) return PMFlowAgentWorkspaceBinding( scope_kind="space", binding_strength="space_without_project_anchor", goal_scope=goal_scope, candidates=candidates, facts=facts, gaps=[ { "type": "object_anchor_gap", "summary": "本轮没有结构化项目锚点;不会把可见项目列表自动当作当前项目。", "customer_safe_summary": "如果要推进某一个项目,需要从项目页进入或点名项目。", "user_actionable": False, } ], ) class PMFlowAgentPlanner: """Create a bounded step queue. The planner never writes the final answer.""" def plan(self, *, binding: PMFlowAgentWorkspaceBinding) -> list[PMFlowAgentStepPlan]: plans = [ PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="read_capability_contract", label="读取能力边界", summary="确认 PMFlow AI 本轮能公开说明和不能越界的范围。", ), PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="read_space_state", label="读取空间状态", summary="读取当前空间的项目、下一步、待确认和 AI 进行中摘要。", ), ] if binding.scope_kind == "project" and binding.goal_scope == "project_operating" and binding.object_ref: plans.extend( [ PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="read_project_world_state", label="读取项目运行状态", summary="读取结构化项目锚点对应的运行状态和证据覆盖。", tool_input={"project_ref": binding.object_ref}, ), PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="run_specialist_agent", label="组织专业角色", summary="让策略、用户验证、产品、技术、执行和风险角色消费同一份项目状态。", ), PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="run_council_review", label="会审推进顺序", summary="收敛角色产物,形成单一优先级和冲突边界。", ), PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="apply_autonomy_policy", label="应用确认门禁", summary="只准备候选动作、确认请求和执行交接包,不直接写入或执行。", ), PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="prepare_action_packet", label="整理动作包", summary="把可建议、需确认和被阻断的动作整理为客户安全产物。", ), PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="record_learning_signal", label="记录学习闭环", summary="读取上一轮学习账本,记录本轮观察和下一轮触发条件。", ), ] ) return plans class PMFlowAgentStepVerifier: """Verify each step before the loop can continue.""" def verify(self, execution: PMFlowAgentStepExecution) -> PMFlowAgentVerificationPacket: findings: list[dict[str, Any]] = [] result = "passed" retry_would_change_state = False if execution.step.status in {"failed", "blocked"}: result = "blocked" retry_would_change_state = False if not execution.observations and execution.plan.tool_name.startswith("read_"): result = "blocked" findings.append({"type": "missing_observation", "summary": "该读取步骤没有产生可用观察。"}) for observation in execution.observations: for gap in list(observation.gaps or [])[:8]: if not isinstance(gap, dict): continue findings.append( { "type": str(gap.get("type") or "remaining_gap")[:80], "summary": _customer_gap_summary(gap), } ) for fact in list(observation.facts or [])[:8]: if isinstance(fact, dict) and str(fact.get("type") or "") in {"tool_error", "tool_rejected"}: result = "blocked" findings.append({"type": str(fact.get("type")), "summary": _customer_gap_summary(fact)}) return PMFlowAgentVerificationPacket( target_ref=f"step:{execution.step.step_id}", result=result, retry_would_change_state=retry_would_change_state, findings=findings[:12], ) class PMFlowAgentRecoveryPlanner: """Create a customer-safe recovery step for failed observations.""" def recover(self, *, failed: PMFlowAgentStepExecution) -> PMFlowAgentStepPlan | None: if failed.step.status not in {"failed", "blocked"} and not any( str(item.target_ref or "").startswith("step:") and item.result != "passed" for item in failed.verifications ): return None step_verifications = [item for item in failed.verifications if str(item.target_ref or "").startswith("step:")] if step_verifications and all(item.result == "passed" for item in step_verifications) and failed.step.status not in {"failed", "blocked"}: return None return PMFlowAgentStepPlan( step_id=new_pmflow_agent_id("plan"), tool_name="recover_from_step_failure", label="定位失败原因", summary="把失败步骤转成客户安全原因和后续可恢复动作。", tool_input={ "failed_step": failed.plan.tool_name, "findings": [finding for verification in failed.verifications for finding in list(verification.findings or [])[:8]], }, ) class PMFlowAgentExecutorRegistry: """Execute Agent OS plans as runtime tools or specialist modules.""" def __init__( self, *, db: Any, tenant_id: int, tool_registry: Any | None = None, specialist_runtime: Any | None = None, ) -> None: self.db = db self.tenant_id = int(tenant_id or 1) self.tool_registry = tool_registry or PMFlowAIAgentToolRegistry(db=db, tenant_id=self.tenant_id) self.specialist_runtime = specialist_runtime or PMFlowAgentSpecialistRuntimeService(db=db, tenant_id=self.tenant_id) self._specialist_result: PMFlowAgentSpecialistResult | None = None async def execute( self, *, plan: PMFlowAgentStepPlan, request: RAGAskRequest, current_user: Any, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, binding: PMFlowAgentWorkspaceBinding, ) -> PMFlowAgentStepExecution: started = time.perf_counter() observations: list[PMFlowAgentObservationPacket] = [] artifacts: list[PMFlowAgentArtifactPacket] = [] memories: list[PMFlowAgentMemoryPacket] = [] payload_delta: dict[str, Any] = {} status = "completed" summary = plan.summary if plan.tool_name == "read_capability_contract": observation = await self._execute_agent_tool( tool_name="inspect_pmflow_capability_boundary", tool_input={}, request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) observations.append(_observation_from_agent_tool(observation, step_id=plan.step_id)) summary = observation.summary elif plan.tool_name == "read_space_state": observation = await self._execute_agent_tool( tool_name="inspect_space_collections", tool_input={"question": _text(getattr(request, "question", ""), 1200)}, request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) observations.append(_observation_from_agent_tool(observation, step_id=plan.step_id)) summary = observation.summary elif plan.tool_name == "read_project_world_state": observation = await self._execute_agent_tool( tool_name="inspect_project_operating_state", tool_input=plan.tool_input or {"project_ref": binding.object_ref}, request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) observations.append(_observation_from_agent_tool(observation, step_id=plan.step_id)) summary = observation.summary if observation.observation_type.endswith("missing_scope"): status = "blocked" elif plan.tool_name == "run_specialist_agent": specialist = await self._ensure_specialist_result( request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) observations.append( PMFlowAgentObservationPacket( step_id=plan.step_id, source_kind="specialist_agents", summary=_specialist_observation_summary(specialist), confidence="high" if specialist.work_products else "medium", facts=[ {"type": "specialist_assignment_count", "count": len(specialist.assignments)}, {"type": "specialist_work_product_count", "count": len(specialist.work_products)}, ], gaps=_specialist_gaps(specialist), ) ) artifacts.extend(specialist.artifacts) execution_verifications = list(specialist.verifications or []) payload_delta["specialist_verifications"] = execution_verifications payload_delta.update(_specialist_payload_delta(specialist)) summary = _specialist_observation_summary(specialist) if specialist.skipped_reason: status = "blocked" elif plan.tool_name == "run_council_review": specialist = await self._ensure_specialist_result( request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) review = specialist.council_review observations.append( PMFlowAgentObservationPacket( step_id=plan.step_id, source_kind="agent_council_review", summary=_text(getattr(review, "resolution_summary", ""), 800) or "已完成专业角色会审。", confidence="high" if review is not None else "unknown", facts=[{"type": "agent_council_review", "status": _text(getattr(review, "status", ""), 80)}], ) ) payload_delta.update(_specialist_payload_delta(specialist)) summary = _text(getattr(review, "resolution_summary", ""), 600) or plan.summary elif plan.tool_name == "apply_autonomy_policy": specialist = await self._ensure_specialist_result( request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) gate = specialist.autonomy_gate observations.append( PMFlowAgentObservationPacket( step_id=plan.step_id, source_kind="autonomy_gate", summary=_autonomy_summary(gate), confidence="high" if gate is not None else "unknown", facts=[ {"type": "prepared_action_count", "count": len(list(getattr(gate, "prepared_action_packets", []) or []))}, {"type": "confirmation_request_count", "count": len(list(getattr(gate, "confirmation_requests", []) or []))}, ], ) ) payload_delta.update(_specialist_payload_delta(specialist)) summary = _autonomy_summary(gate) elif plan.tool_name == "prepare_action_packet": specialist = await self._ensure_specialist_result( request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) payload_delta.update(_specialist_payload_delta(specialist)) packets = list(getattr(specialist.autonomy_gate, "prepared_action_packets", []) or []) if specialist.autonomy_gate is not None else [] requests = list(getattr(specialist.autonomy_gate, "confirmation_requests", []) or []) if specialist.autonomy_gate is not None else [] observations.append( PMFlowAgentObservationPacket( step_id=plan.step_id, source_kind="action_packet", summary=f"已整理 {len(packets)} 个候选动作包和 {len(requests)} 个确认请求,全部停在确认门禁。", confidence="high", facts=[{"type": "action_packet", "prepared_count": len(packets), "confirmation_count": len(requests)}], ) ) summary = observations[-1].summary elif plan.tool_name == "record_learning_signal": specialist = await self._ensure_specialist_result( request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) payload_delta.update(_specialist_payload_delta(specialist)) cycle = specialist.operating_cycle memories.extend(_learning_memories(specialist)) observations.append( PMFlowAgentObservationPacket( step_id=plan.step_id, source_kind="learning_loop", summary=_learning_summary(cycle), confidence="high" if cycle is not None else "unknown", facts=[{"type": "learning_loop", "memory_count": len(memories)}], ) ) summary = _learning_summary(cycle) elif plan.tool_name == "recover_from_step_failure": findings = [item for item in list(plan.tool_input.get("findings") or []) if isinstance(item, dict)] observations.append( PMFlowAgentObservationPacket( step_id=plan.step_id, source_kind="recovery", summary=_recovery_summary(findings), confidence="medium", facts=[{"type": "recovery_step", "failed_step": _text(plan.tool_input.get("failed_step"), 120)}], ) ) summary = observations[-1].summary else: status = "blocked" observations.append( PMFlowAgentObservationPacket( step_id=plan.step_id, source_kind="executor_registry", summary="该步骤还没有接入可执行模块,本轮未把它包装成结果。", confidence="unknown", facts=[{"type": "tool_rejected", "tool_name": plan.tool_name}], ) ) step = PMFlowAgentStepPacket( step_id=plan.step_id, kind=plan.tool_name, status=status, label=plan.label, summary=summary, tool_name=plan.tool_name, duration_ms=int((time.perf_counter() - started) * 1000), ) execution = PMFlowAgentStepExecution( plan=plan, step=step, observations=observations, artifacts=artifacts, memories=memories, payload_delta=payload_delta, ) for verification in list(payload_delta.get("specialist_verifications") or []): if isinstance(verification, PMFlowAgentVerificationPacket): execution.verifications.append(verification) return execution async def _execute_agent_tool( self, *, tool_name: str, tool_input: dict[str, Any], request: RAGAskRequest, current_user: Any, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> PMFlowAIAgentObservation: return await self.tool_registry.execute( proposal=PMFlowAIAgentStepProposal( action="tool", tool_name=tool_name, tool_input=tool_input, rationale="Agent OS executor selected read-only tool", expected_observation="customer-safe observation", confidence="medium", ), request=request, workspace=None, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) async def _ensure_specialist_result( self, *, request: RAGAskRequest, current_user: Any, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> PMFlowAgentSpecialistResult: if self._specialist_result is None: self._specialist_result = await self.specialist_runtime.run( request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) return self._specialist_result class PMFlowAgentLoop: def __init__( self, *, binder: PMFlowAgentWorkspaceBinder | None = None, planner: PMFlowAgentPlanner | None = None, executor: PMFlowAgentExecutorRegistry, verifier: PMFlowAgentStepVerifier | None = None, recovery_planner: PMFlowAgentRecoveryPlanner | None = None, max_steps: int = 10, ) -> None: self.binder = binder or PMFlowAgentWorkspaceBinder() self.planner = planner or PMFlowAgentPlanner() self.executor = executor self.verifier = verifier or PMFlowAgentStepVerifier() self.recovery_planner = recovery_planner or PMFlowAgentRecoveryPlanner() self.max_steps = max(3, min(int(max_steps), 16)) async def run( self, *, request: RAGAskRequest, current_user: Any, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> PMFlowAgentLoopResult: binding = self.binder.bind( request=request, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) result = PMFlowAgentLoopResult(binding=binding) bind_step_id = new_pmflow_agent_id("bind") result.steps.append( PMFlowAgentStepPacket( step_id=bind_step_id, index=0, kind="workspace_binding", status="completed", label="绑定工作区", summary=_binding_summary(binding), ) ) result.observations.append( PMFlowAgentObservationPacket( step_id=bind_step_id, source_kind="workspace_binder", summary=_binding_summary(binding), confidence="high" if binding.object_ref else "medium", facts=list(binding.facts or []), gaps=list(binding.gaps or []), ) ) queue = self.planner.plan(binding=binding) executed = 0 while queue and executed < self.max_steps: plan = queue.pop(0) execution = await self.executor.execute( plan=plan, request=request, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, binding=binding, ) verification = self.verifier.verify(execution) execution.verifications.append(verification) result.steps.append(execution.step.model_copy(update={"index": len(result.steps)})) result.observations.extend(execution.observations) result.artifacts.extend(execution.artifacts) result.verifications.extend(execution.verifications) result.memories.extend(execution.memories) result.answer_inputs.update(execution.payload_delta) if "specialist_result" in execution.payload_delta: candidate = execution.payload_delta["specialist_result"] if isinstance(candidate, PMFlowAgentSpecialistResult): result.specialist_result = candidate recovery = self.recovery_planner.recover(failed=execution) if recovery is not None and not any(item.tool_name == recovery.tool_name for item in queue): queue.insert(0, recovery) executed += 1 if queue: result.status = "blocked" result.verifications.append( PMFlowAgentVerificationPacket( target_ref="agent_loop", result="blocked", retry_would_change_state=True, findings=[{"type": "step_budget_exhausted", "summary": "本轮已达到最大步骤数,后续步骤需要继续 run。"}], ) ) result.command_payload = dict(result.answer_inputs.get("command_payload") or {}) return result def _observation_from_agent_tool(observation: PMFlowAIAgentObservation, *, step_id: str) -> PMFlowAgentObservationPacket: facts = [_sanitize_fact(item) for item in list(getattr(observation, "facts", []) or [])[:24] if isinstance(item, dict)] gaps = [ { "type": "evidence_gap", "summary": _customer_gap_summary(item), "user_actionable": bool(item.get("user_actionable", False)), } for item in facts if isinstance(item, dict) and ("gap" in str(item.get("type") or "") or item.get("gap")) ] return PMFlowAgentObservationPacket( observation_id=str(getattr(observation, "observation_id", "") or new_pmflow_agent_id("obs"))[:80], step_id=step_id, source_kind=str(getattr(observation, "source", "") or "agent_tool")[:80], summary=_text(getattr(observation, "summary", ""), 800), confidence=str(getattr(observation, "confidence", "") or "unknown")[:32], facts=facts, gaps=gaps[:12], ) def _sanitize_fact(fact: dict[str, Any]) -> dict[str, Any]: out = dict(fact or {}) if str(out.get("gap") or "") == "project_object_required_for_world_state": out["gap"] = "需要明确项目对象后才能读取项目运行状态" if str(out.get("type") or "") == "project_world_state_gap": out["summary"] = _customer_gap_summary(out) out.pop("gap", None) return out def _customer_gap_summary(value: dict[str, Any]) -> str: text = _text(value.get("customer_safe_summary") or value.get("summary") or value.get("title"), 500) if text: return text raw_gap = str(value.get("gap") or value.get("type") or "").strip() if raw_gap in {"project_object_required_for_world_state", "project_world_state_gap"}: return "需要明确项目对象后才能读取项目运行状态。" if raw_gap == "project_object_not_resolved": return "没有读取到匹配的客户安全项目对象。" if raw_gap: return raw_gap[:240] return "该步骤还有未补齐的信息。" def _specialist_payload_delta(specialist: PMFlowAgentSpecialistResult) -> dict[str, Any]: return { "specialist_result": specialist, "agent_os_specialists": specialist.customer_safe_payload(), "specialist_assignments": [item.model_dump(mode="json", exclude_none=True) for item in specialist.assignments], "work_products": [item.model_dump(mode="json", exclude_none=True) for item in specialist.work_products], "command_payload": dict(specialist.command_payload or {}), } def _specialist_observation_summary(specialist: PMFlowAgentSpecialistResult) -> str: if specialist.skipped_reason: return specialist.skipped_reason return f"已组织 {len(specialist.assignments)} 个专业角色,生成 {len(specialist.work_products)} 个工作产物。" def _specialist_gaps(specialist: PMFlowAgentSpecialistResult) -> list[dict[str, Any]]: if not specialist.skipped_reason: return [] return [{"type": "specialist_runtime_skipped", "summary": specialist.skipped_reason, "user_actionable": False}] def _autonomy_summary(gate: Any) -> str: if gate is None: return "未生成确认门禁。" prepared = len(list(getattr(gate, "prepared_action_packets", []) or [])) confirmations = len(list(getattr(gate, "confirmation_requests", []) or [])) executions = len(list(getattr(gate, "execution_preparations", []) or [])) blocked = len(list(getattr(gate, "blocked_actions", []) or [])) return f"已准备 {prepared} 个候选动作包、{confirmations} 个确认请求和 {executions} 个执行交接包;{blocked} 个动作保持阻断。" def _learning_summary(cycle: Any) -> str: if cycle is None: return "未建立学习闭环。" consumed = len(list(getattr(getattr(cycle, "consumed_learning_ledger", None), "learning_items", []) or [])) observations = len(list(getattr(cycle, "outcome_observations", []) or [])) triggers = len(list(getattr(cycle, "next_cycle_triggers", []) or [])) return f"已消费 {consumed} 条历史学习,记录 {observations} 条本轮观察,并准备 {triggers} 类下一轮触发。" def _learning_memories(specialist: PMFlowAgentSpecialistResult) -> list[PMFlowAgentMemoryPacket]: cycle = specialist.operating_cycle if cycle is None: return [] ledger = getattr(cycle, "learning_ledger", None) return [ PMFlowAgentMemoryPacket( memory_kind="learning_ledger_snapshot", scope_ref=_text(getattr(ledger, "scope_ref", ""), 160) or None, outcome_signal="updated", summary=_learning_summary(cycle), ) ] def _recovery_summary(findings: list[dict[str, Any]]) -> str: if not findings: return "上一步没有产生可恢复的客户安全发现,本轮停止重复执行。" return "上一步未完整完成,原因是:" + ";".join(_customer_gap_summary(item) for item in findings[:3]) + "。" def _binding_summary(binding: PMFlowAgentWorkspaceBinding) -> str: if binding.scope_kind == "project" and binding.object_ref: return f"已绑定结构化项目对象 {binding.object_ref},可以进入项目经营路径。" if binding.object_ref and binding.goal_scope == "capability": return "本轮是能力说明,不使用页面上的项目锚点覆盖用户问题。" if binding.candidates: return f"已读取 {len(binding.candidates)} 个候选对象,但本轮没有强项目经营目标。" return "已绑定为空间级工作区。" def _strong_project_ref( *, request: RAGAskRequest, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> str | None: request_project_id = _safe_int(getattr(request, "project_id", None)) if request_project_id is not None: return f"project:{request_project_id}" context_project_id = _safe_int((conversation_context or {}).get("current_project_id")) if context_project_id is not None: return f"project:{context_project_id}" surface = _surface_context(tenant_project_context) focus = surface.get("current_focus_object") if isinstance(focus, dict): object_ref = _text(focus.get("object_ref"), 160) object_type = _text(focus.get("object_type"), 80) object_id = _safe_int(focus.get("object_id")) if object_ref.startswith("project:"): return object_ref if object_type == "project" and object_id is not None: return f"project:{object_id}" return None def _visible_project_candidates(tenant_project_context: dict[str, Any] | None) -> list[dict[str, Any]]: surface = _surface_context(tenant_project_context) candidates: list[dict[str, Any]] = [] for item in list(surface.get("visible_objects") or [])[:20]: if not isinstance(item, dict): continue object_ref = _text(item.get("object_ref"), 160) object_type = _text(item.get("object_type"), 80) if object_ref.startswith("project:") or object_type == "project": candidates.append(dict(item)) runtime_context = surface.get("space_runtime_context") if isinstance(surface.get("space_runtime_context"), dict) else {} workbench = runtime_context.get("workbench") if isinstance(runtime_context.get("workbench"), dict) else surface.get("workbench") if isinstance(workbench, dict): for key in ("projects", "recent_projects"): for item in list(workbench.get(key) or [])[:20]: if isinstance(item, dict): candidates.append(dict(item)) return candidates[:20] def _surface_context(tenant_project_context: dict[str, Any] | None) -> dict[str, Any]: context = dict(tenant_project_context or {}) surface = context.get("surface_context") return dict(surface) if isinstance(surface, dict) else {} def _project_id_from_ref(ref: str | None) -> int | None: text = str(ref or "").strip() if text.startswith("project:"): return _safe_int(text.split(":", 1)[1]) return _safe_int(text) def _goal_scope_from_question(question: Any) -> str: """Small disclosure guard, not business routing. Business capabilities must still be selected by structured objects and runtime steps. This guard prevents product-identity/capability questions from being swallowed by a stale page-level project anchor. """ text = "".join(str(question or "").strip().lower().split()) if not text: return "space_operating" capability_forms = { "你是谁", "你是谁?", "你是谁?", "你能做什么", "你可以做什么", "你有什么能力", "pmflowai是谁", "pmflowai能做什么", } if text in capability_forms: return "capability" return "space_operating" def _safe_int(value: Any) -> int | None: try: if value is None or value == "": return None return int(value) except Exception: return None def _text(value: Any, limit: int = 1000) -> str: return " ".join(str(value or "").strip().split())[:limit] __all__ = [ "PMFlowAgentExecutorRegistry", "PMFlowAgentLoop", "PMFlowAgentLoopResult", "PMFlowAgentPlanner", "PMFlowAgentRecoveryPlanner", "PMFlowAgentStepExecution", "PMFlowAgentStepPlan", "PMFlowAgentStepVerifier", "PMFlowAgentWorkspaceBinder", "PMFlowAgentWorkspaceBinding", ]