PMFlow/backend/app/services/pmflow_agent_runtime/loop.py

841 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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