841 lines
37 KiB
Python
841 lines
37 KiB
Python
"""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",
|
||
]
|