200 lines
9.0 KiB
Python
200 lines
9.0 KiB
Python
"""RC-BE-006: agent catalog, selector, and candidate plans."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
|
from app.report_collaboration.planning.assembler import AssembledPlan, NodeBinding, PlanAssembler
|
|
from app.report_collaboration.planning.catalog import project_agent_catalog
|
|
from app.report_collaboration.planning.controller import PlanController
|
|
from app.report_collaboration.planning.selector import AgentSelector
|
|
from app.report_collaboration.planning.service import PlanProposalService
|
|
from app.report_collaboration.planning.validator import validate_assembled_plan
|
|
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
|
|
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
|
|
|
|
|
|
class _AgentStore:
|
|
def __init__(self, rows: list[dict]) -> None:
|
|
self._rows = rows
|
|
|
|
async def list_visible(self, user_id: str) -> list[dict]:
|
|
return list(self._rows)
|
|
|
|
async def get_extras_for(self, agent_ids: list[str]) -> dict[str, dict]:
|
|
return {row["id"]: {"skills": ["web_search"], "tool_groups": ["web"], "model": "demo"} for row in self._rows if row["id"] in agent_ids}
|
|
|
|
|
|
def _requirement() -> ReportRequirementSnapshot:
|
|
return ReportRequirementSnapshot(
|
|
topic="新能源汽车主要品牌市场趋势",
|
|
audience="企业战略决策人员",
|
|
required_angles=["市场份额", "价格策略", "海外进展", "政策环境"],
|
|
excluded_angles=[],
|
|
revision=1,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_strips_secrets_and_keeps_builtin_roles() -> None:
|
|
store = _AgentStore(
|
|
[
|
|
{
|
|
"id": "ag-research",
|
|
"name": "行业研究员",
|
|
"description": "负责检索与研究",
|
|
"soul": "SECRET_SOUL",
|
|
"api_key": "sk-secret",
|
|
"base_url": "https://hidden.example",
|
|
}
|
|
]
|
|
)
|
|
catalog = await project_agent_catalog(user_id="u1", config=ReportCollaborationConfig(), agent_store=store)
|
|
dumped = [item.model_dump() for item in catalog.agents]
|
|
blob = str(dumped)
|
|
assert "SECRET_SOUL" not in blob
|
|
assert "sk-secret" not in blob
|
|
assert "hidden.example" not in blob
|
|
assert any(item.agent_id == "ag-research" for item in catalog.agents)
|
|
assert any(item.agent_id == "rc.role.reviewer" for item in catalog.agents)
|
|
assert "web_search" in catalog.allowed_tool_names
|
|
|
|
|
|
def test_selector_prefers_visible_user_agent() -> None:
|
|
async def _run() -> None:
|
|
catalog = await project_agent_catalog(
|
|
user_id="u1",
|
|
config=ReportCollaborationConfig(),
|
|
agent_store=_AgentStore([{"id": "ag-research", "name": "行业研究员", "description": "检索研究"}]),
|
|
)
|
|
agent, reason = AgentSelector().pick("researcher", catalog.agents)
|
|
assert agent.agent_id == "ag-research"
|
|
assert "用户可见" in reason
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_three_strategies_differ_and_stay_acyclic() -> None:
|
|
import asyncio
|
|
|
|
async def _run() -> None:
|
|
cfg = ReportCollaborationConfig()
|
|
catalog = await project_agent_catalog(user_id="u1", config=cfg, agent_store=None)
|
|
requirement = _requirement()
|
|
strategies = PlanController().propose(requirement, max_parallel_tasks=cfg.max_parallel_tasks, max_team_members=cfg.max_team_members)
|
|
assert [item.strategy for item in strategies] == ["balanced_review", "parallel_depth", "focused_fast"]
|
|
assembler = PlanAssembler()
|
|
assembled = [
|
|
assembler.assemble(item, requirement=requirement, catalog=catalog, config=cfg, proposal_group_id="g1")
|
|
for item in strategies
|
|
]
|
|
node_counts = [len(item.candidate.nodes) for item in assembled]
|
|
costs = [item.candidate.estimated_cost_level for item in assembled]
|
|
gates = [tuple(gate.id for gate in item.candidate.quality_gates) for item in assembled]
|
|
assert node_counts[1] > node_counts[0] > node_counts[2]
|
|
assert costs == ["medium", "high", "low"]
|
|
assert "dual_verification" in gates[1]
|
|
assert "dual_verification" not in gates[0]
|
|
assert assembled[0].candidate.recommended is True
|
|
group_ids = {item.candidate.proposal_group_id for item in assembled}
|
|
assert len(group_ids) == 1
|
|
for item in assembled:
|
|
validation = validate_assembled_plan(item, catalog=catalog, config=cfg)
|
|
assert validation.ok, validation.errors
|
|
assert all(node.output_artifact_type for node in item.candidate.nodes)
|
|
assert all(node.acceptance_criteria for node in item.candidate.nodes)
|
|
assert all(binding.agent_id.startswith("rc.role.") for binding in item.bindings)
|
|
assert all(tool in cfg.allowed_tools for node in item.candidate.nodes for tool in node.allowed_tools)
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_validator_rejects_unknown_agent_id() -> None:
|
|
import asyncio
|
|
|
|
async def _run() -> None:
|
|
cfg = ReportCollaborationConfig()
|
|
catalog = await project_agent_catalog(user_id="u1", config=cfg, agent_store=None)
|
|
requirement = _requirement()
|
|
strategy = PlanController().propose(requirement, max_parallel_tasks=4, max_team_members=8)[2]
|
|
assembled = PlanAssembler().assemble(strategy, requirement=requirement, catalog=catalog, config=cfg, proposal_group_id="g1")
|
|
assembled.bindings[0] = NodeBinding(
|
|
node_id=assembled.bindings[0].node_id,
|
|
agent_id="not-in-catalog",
|
|
agent_name="幽灵",
|
|
role_key="researcher",
|
|
reason="hallucinated",
|
|
)
|
|
validation = validate_assembled_plan(assembled, catalog=catalog, config=cfg)
|
|
assert validation.ok is False
|
|
assert any("目录外" in item for item in validation.errors)
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_writes_plans_without_starting_a_run() -> None:
|
|
store = MemoryReportCollaborationStore()
|
|
session = await store.create_session(owner_id="u1", title="", idempotency_key="s1")
|
|
await store.save_requirement_state(
|
|
session["id"],
|
|
requirement_json={"snapshot": _requirement().model_dump(), "clarifications": [], "assumptions": [], "clarification_round": 1, "ready_to_plan": True},
|
|
requirement_revision=1,
|
|
status="clarifying",
|
|
)
|
|
command = await store.enqueue_plan_request(session["id"], idempotency_key="ik-plan", expected_revision=1)
|
|
saved = await PlanProposalService(store).fulfill(session["id"], user_id="u1", command_id=command["command_id"], idempotency_key="ik-plan")
|
|
assert len(saved) == 3
|
|
snapshot = await store.get_snapshot(session["id"])
|
|
assert snapshot["session"]["status"] == "proposal_ready"
|
|
assert snapshot["session"]["selected_plan_id"] is None
|
|
assert snapshot["run"] is None
|
|
assert {item["strategy"] for item in snapshot["plans"]} == {"balanced_review", "parallel_depth", "focused_fast"}
|
|
assert all(item["requirement_revision"] == 1 for item in snapshot["plans"])
|
|
assert snapshot["session"]["active_run_id"] is None
|
|
|
|
selected = await store.select_plan(session["id"], saved[0]["id"], idempotency_key="ik-select", expected_revision=1)
|
|
after_select = await store.get_snapshot(session["id"])
|
|
assert selected["selected_plan_id"] == saved[0]["id"]
|
|
assert after_select["run"] is None
|
|
assert after_select["session"]["active_run_id"] is None
|
|
|
|
|
|
def test_cycle_is_rejected() -> None:
|
|
cfg = ReportCollaborationConfig()
|
|
candidate_nodes_cycle = AssembledPlan.model_validate(
|
|
{
|
|
"candidate": {
|
|
"id": "p1",
|
|
"proposal_group_id": "g",
|
|
"title": "环",
|
|
"strategy": "focused_fast",
|
|
"summary": "s",
|
|
"rationale": "r",
|
|
"estimated_duration_seconds": 10,
|
|
"estimated_cost_level": "low",
|
|
"requirement_revision": 1,
|
|
"roles": [],
|
|
"nodes": [
|
|
{"id": "a", "label": "A", "depends_on": ["b"], "output_artifact_type": "EvidenceBundle", "acceptance_criteria": ["x"]},
|
|
{"id": "b", "label": "B", "depends_on": ["a"], "output_artifact_type": "VerificationResult", "acceptance_criteria": ["x"]},
|
|
],
|
|
"edges": [{"source": "b", "target": "a"}, {"source": "a", "target": "b"}],
|
|
"quality_gates": [{"id": "cite_required", "label": "来源"}],
|
|
"validation": {"ok": True, "errors": []},
|
|
"revision": 1,
|
|
"status": "proposed",
|
|
},
|
|
"bindings": [],
|
|
}
|
|
)
|
|
from app.report_collaboration.planning.catalog import AgentCatalogProjection
|
|
|
|
catalog = AgentCatalogProjection(agents=[], tools=[], allowed_tool_names=list(cfg.allowed_tools))
|
|
validation = validate_assembled_plan(candidate_nodes_cycle, catalog=catalog, config=cfg)
|
|
assert validation.ok is False
|
|
assert any("循环" in item for item in validation.errors)
|