Files

362 lines
15 KiB
Python

"""RC-BE-007: DeerFlow model freeze + AgentScope chat adapter."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.report_collaboration.agentscope_runtime.event_adapter import ConversationReplica, MemberStreamContext, SeqAllocator, make_emitter
from app.report_collaboration.agentscope_runtime.model_adapter import DeerFlowChatModelAdapter, ModelAdapterError, estimate_tokens_for_text, record_attempt_usage, redact_secrets
from app.report_collaboration.agentscope_runtime.model_resolver import freeze_plan_models, resolve_role_model
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
from deerflow.persistence.report_collaboration import ReportCollaborationValidationError
def _app(*, models: list[ModelConfig] | None = None, rc: ReportCollaborationConfig | None = None) -> AppConfig:
return AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [item.model_dump() for item in (models or [ModelConfig(name="demo-chat", use="langchain_openai:ChatOpenAI", model="demo")])],
"report_collaboration": (rc or ReportCollaborationConfig()).model_dump(),
}
)
def _plan(*, mission: str = "检索证据", role: str = "researcher") -> dict:
return {
"nodes": [
{
"id": "research-0",
"label": "研究",
"role_key": role,
"mission": mission,
"output_artifact_type": "EvidenceBundle",
"allowed_tools": ["web_search"],
"acceptance_criteria": ["有来源"],
"depends_on": [],
}
],
"roles": [{"key": role, "display_name": "研究员", "responsibility": "检索"}],
}
class _FakeLLM:
def __init__(self, chunks: list | None = None, error: Exception | None = None) -> None:
self.chunks = chunks or []
self.error = error
self.bound_tools = None
def bind_tools(self, tools):
self.bound_tools = tools
return self
async def astream(self, messages):
_ = messages
if self.error is not None:
raise self.error
for chunk in self.chunks:
yield chunk
def test_named_model_missing_fails_before_run() -> None:
app = _app(rc=ReportCollaborationConfig(research_model="missing-model"))
with pytest.raises(ReportCollaborationValidationError) as exc:
freeze_plan_models(_plan(), app)
assert exc.value.code == "MODEL_NOT_FOUND"
def test_unspecified_role_falls_back_and_is_recorded() -> None:
planner = ModelConfig(name="planner", use="langchain_openai:ChatOpenAI", model="p1")
app = _app(models=[planner], rc=ReportCollaborationConfig(planner_model="planner"))
snapshot = resolve_role_model("researcher", app_config=app)
assert snapshot.config_name == "planner"
assert snapshot.fallback_reason == "role_unspecified_use_planner"
assert snapshot.frozen is True
dumped = snapshot.model_dump()
assert "api_key" not in dumped
assert "base_url" not in dumped
def test_vision_capability_mismatch() -> None:
app = _app()
with pytest.raises(ReportCollaborationValidationError) as exc:
freeze_plan_models(_plan(mission="分析配图与图像来源"), app)
assert exc.value.code == "MODEL_CAPABILITY_MISMATCH"
def test_snapshot_strips_provider_secrets() -> None:
model = ModelConfig(name="secret-chat", use="langchain_openai:ChatOpenAI", model="gpt", api_key="sk-SECRETVALUE", base_url="https://hidden.example")
app = _app(models=[model], rc=ReportCollaborationConfig(research_model="secret-chat"))
bundle = freeze_plan_models(_plan(), app)
blob = str(bundle.model_dump())
assert "sk-SECRETVALUE" not in blob
assert "hidden.example" not in blob
assert bundle.by_role["researcher"].config_name == "secret-chat"
@pytest.mark.asyncio
async def test_adapter_streams_text_tool_and_usage() -> None:
snapshot = resolve_role_model("researcher", app_config=_app())
llm = _FakeLLM(
chunks=[
SimpleNamespace(content="市", tool_call_chunks=[], usage_metadata=None, response_metadata={"api_key": "sk-LEAK"}),
SimpleNamespace(
content="场",
tool_call_chunks=[SimpleNamespace(id="call_1", name="web_search", args='{"q":')],
usage_metadata={"input_tokens": 12, "output_tokens": 4, "total_tokens": 16},
response_metadata={},
),
]
)
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app(), llm_factory=lambda: llm, node_run_id="research-0-attempt1")
pieces = [item async for item in adapter.astream_chat([{"role": "user", "content": "写报告"}], tools=[{"name": "web_search"}])]
assert llm.bound_tools == [{"name": "web_search"}]
assert any(not item.is_last and item.content and getattr(item.content[0], "text", "") == "市" for item in pieces)
last = pieces[-1]
assert last.is_last is True
assert last.usage is not None
assert last.usage.input_tokens == 12
assert adapter.last_usage.total_tokens == 16
assert adapter.last_usage.node_run_id == "research-0-attempt1"
assert "[REDACTED]" in str(pieces[0].metadata.get("api_key"))
adapter.refuse_swap("demo-chat")
with pytest.raises(ModelAdapterError) as frozen:
adapter.refuse_swap("other-model")
assert frozen.value.code == "MODEL_FROZEN"
@pytest.mark.asyncio
async def test_adapter_streams_reasoning_blocks_and_inline_think_tags() -> None:
snapshot = resolve_role_model("writer", app_config=_app())
llm = _FakeLLM(
chunks=[
SimpleNamespace(
content="",
additional_kwargs={"reasoning_content": "先整理证据。"},
tool_call_chunks=[],
usage_metadata=None,
response_metadata={},
),
SimpleNamespace(
content="<thi",
additional_kwargs={},
tool_call_chunks=[],
usage_metadata=None,
response_metadata={},
),
SimpleNamespace(
content="nk>再组织章节。</think>报告正文",
additional_kwargs={},
tool_call_chunks=[],
usage_metadata=None,
response_metadata={},
),
]
)
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app(), llm_factory=lambda: llm)
pieces = [item async for item in adapter.astream_chat([{"role": "user", "content": "开始"}])]
blocks = [block for item in pieces[:-1] for block in item.content]
assert "先整理证据。" in "".join(str(getattr(block, "thinking", "") or "") for block in blocks)
assert "再组织章节。" in "".join(str(getattr(block, "thinking", "") or "") for block in blocks)
assert "".join(str(getattr(block, "text", "") or "") for block in blocks) == "报告正文"
assert len(pieces[-1].content) == 1
assert getattr(pieces[-1].content[0], "text", "") == "报告正文"
@pytest.mark.asyncio
async def test_adapter_count_tokens_and_context_size_contract() -> None:
"""AgentScope compress_context 依赖 model.context_size 与 count_tokens(messages, tools)。"""
snapshot = resolve_role_model("researcher", app_config=_app())
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app())
assert adapter.model == snapshot.provider_model
assert isinstance(adapter.context_size, int) and adapter.context_size > 0
class _Msg:
def __init__(self, text: str) -> None:
self.content = text
def get_content_blocks(self):
return [{"type": "text", "text": self.content}]
agentscope_style = [_Msg("市场分析" * 20)]
tokens_msg = await adapter.count_tokens(agentscope_style, None)
dict_style = [{"role": "user", "content": "hello world"}]
tokens_dict = await adapter.count_tokens(dict_style, None)
tools = [{"name": "web_search", "parameters": {"type": "object"}}]
tokens_tools = await adapter.count_tokens(dict_style, tools)
assert tokens_msg > 0
assert tokens_dict == estimate_tokens_for_text("hello world")
assert tokens_tools > tokens_dict
assert await adapter.count_tokens([], None) == 0
@pytest.mark.asyncio
async def test_adapter_redacts_provider_errors_and_records_metrics() -> None:
snapshot = resolve_role_model("writer", app_config=_app())
llm = _FakeLLM(error=RuntimeError("invalid api_key sk-SECRETVALUE Bearer abc.def"))
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app(), llm_factory=lambda: llm)
with pytest.raises(ModelAdapterError) as exc:
_ = [item async for item in adapter.astream_chat([{"role": "user", "content": "hi"}])]
assert "sk-SECRETVALUE" not in str(exc.value)
assert "[REDACTED]" in str(exc.value)
assert adapter.last_usage.status == "error"
assert "sk-SECRETVALUE" not in (adapter.last_usage.error_message or "")
class _Metrics:
def __init__(self) -> None:
self.rows: list = []
async def record(self, metric) -> None:
self.rows.append(metric)
metrics = _Metrics()
await record_attempt_usage(adapter.last_usage, user_id="u1", run_id="run_1", metrics_store=metrics)
assert metrics.rows[0].status == "error"
assert "sk-SECRETVALUE" not in (metrics.rows[0].error_message or "")
@pytest.mark.asyncio
async def test_protocol_events_feed_conversation_replica() -> None:
snapshot = resolve_role_model("researcher", app_config=_app())
llm = _FakeLLM(chunks=[SimpleNamespace(content="份额上升", additional_kwargs={"reasoning_content": "先核对统计口径"}, tool_call_chunks=[], usage_metadata={"input_tokens": 1, "output_tokens": 2}, response_metadata={})])
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app(), llm_factory=lambda: llm)
replica = ConversationReplica(
MemberStreamContext(
session_id="s1",
run_id="r1",
agent_run_id="agent_1",
node_run_id="research-0-attempt1",
phase_id="p1",
display_name="研究员",
emit_reasoning=True,
),
make_emitter("s1", "r1", SeqAllocator()),
)
types: list[str] = []
async for event in adapter.astream_protocol_events([{"role": "user", "content": "开始"}], display_name="研究员"):
emitted = replica.ingest(event)
types.extend(item.type for item in emitted)
assert types[0] == "message.created"
assert "message.delta" in types
assert types[-1] == "message.completed"
@pytest.mark.asyncio
async def test_adapter_runs_inside_real_agentscope_agent() -> None:
"""Production adapter satisfies the real AgentScope model/block contract."""
from agentscope.agent import Agent, InjectionConfig
from agentscope.message import Msg, UserMsg
snapshot = resolve_role_model("researcher", app_config=_app())
llm = _FakeLLM(
chunks=[
SimpleNamespace(
content="正在分析",
additional_kwargs={},
tool_call_chunks=[],
usage_metadata={"input_tokens": 3, "output_tokens": 2, "total_tokens": 5},
response_metadata={},
)
]
)
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app(), llm_factory=lambda: llm)
agent = Agent(
name="研究员",
system_prompt="完成当前研究任务。",
model=adapter,
injection_config=InjectionConfig(emit_hint_event=False),
)
event_types: list[str] = []
final_msg = None
async for event in agent.reply_stream(UserMsg(name="user", content="分析市场"), yield_final_msg=True):
if isinstance(event, Msg):
final_msg = event
else:
event_types.append(str(getattr(getattr(event, "type", ""), "value", getattr(event, "type", ""))))
assert "MODEL_CALL_START" in event_types
assert "TEXT_BLOCK_DELTA" in event_types
assert final_msg is not None
assert final_msg.get_text_content() == "正在分析"
@pytest.mark.asyncio
async def test_adapter_emits_real_tool_blocks_for_agentscope_structured_output() -> None:
"""Tool calls from LangChain chunks reach AgentScope's structured-output tool."""
from agentscope.agent import Agent, InjectionConfig
from agentscope.message import Msg, UserMsg
from pydantic import BaseModel
class Result(BaseModel):
answer: str
class StructuredLLM(_FakeLLM):
async def astream(self, messages):
_ = messages
tool_name = next(
item["function"]["name"]
for item in (self.bound_tools or [])
if item["function"]["name"].lower() == "generatestructuredoutput"
)
yield SimpleNamespace(
content="",
additional_kwargs={},
tool_call_chunks=[
SimpleNamespace(id="structured-1", name=tool_name, args='{"answer":', index=0)
],
usage_metadata=None,
response_metadata={},
)
yield SimpleNamespace(
content="",
additional_kwargs={},
tool_call_chunks=[SimpleNamespace(id=None, name=None, args='"ok"', index=0)],
usage_metadata=None,
response_metadata={},
)
yield SimpleNamespace(
content="",
additional_kwargs={},
tool_call_chunks=[SimpleNamespace(id=None, name=None, args="}", index=0)],
usage_metadata={"input_tokens": 3, "output_tokens": 3, "total_tokens": 6},
response_metadata={},
)
snapshot = resolve_role_model("researcher", app_config=_app())
llm = StructuredLLM()
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app(), llm_factory=lambda: llm)
agent = Agent(
name="研究员",
system_prompt="返回结构化结果。",
model=adapter,
injection_config=InjectionConfig(emit_hint_event=False),
)
final_msg = None
tool_start_names: list[str] = []
async for event in agent.reply_stream(
UserMsg(name="user", content="开始"),
structured_schema=Result,
yield_final_msg=True,
):
if isinstance(event, Msg):
final_msg = event
elif str(getattr(getattr(event, "type", ""), "value", getattr(event, "type", ""))) == "TOOL_CALL_START":
tool_start_names.append(str(getattr(event, "tool_call_name", "")))
assert final_msg is not None
assert final_msg.structured_output["answer"] == "ok"
assert tool_start_names == ["GenerateStructuredOutput"]
def test_redact_helper() -> None:
assert "sk-abc" not in redact_secrets("using sk-abc and Bearer tok")
assert "[REDACTED]" in redact_secrets("api_key=super-secret")