diff --git a/deepsearch/openjiuwen_deepsearch/framework/openjiuwen/agent/workflow.py b/deepsearch/openjiuwen_deepsearch/framework/openjiuwen/agent/workflow.py index 7d246db0..2a02913d 100644 --- a/deepsearch/openjiuwen_deepsearch/framework/openjiuwen/agent/workflow.py +++ b/deepsearch/openjiuwen_deepsearch/framework/openjiuwen/agent/workflow.py @@ -1398,7 +1398,12 @@ async def run_state_creation_workflow( "total_input_tokens": run_context.total_input_tokens, "total_output_tokens": run_context.total_output_tokens, "log_dir": run_context.log_dir, - "fail_count": run_context.fail_count, + # Pass 0 so the sub-workflow reports a per-action delta (0/1), + # not the running cumulative total. The parent accumulates it + # at the completion site; passing run_context.fail_count here + # caused the total to be double-counted (and inflated under + # parallelism). + "fail_count": 0, }, ) diff --git a/deepsearch/tests/search_agent/test_integration_search_loop.py b/deepsearch/tests/search_agent/test_integration_search_loop.py index 8fc183cb..714ab624 100644 --- a/deepsearch/tests/search_agent/test_integration_search_loop.py +++ b/deepsearch/tests/search_agent/test_integration_search_loop.py @@ -21,6 +21,13 @@ pytestmark = pytest.mark.integration +class ExposedDeepSearchAgent(DeepSearchAgent): + """用于测试的类,公开受保护的方法以遵循 G.CLS.11 规则""" + + async def run_internal(self, *args, **kwargs): + return await super()._run_internal(*args, **kwargs) + + def _make_agent(tmp_log_dir: Path, **pqp_updates: Any): agent = DeepSearchAgent() agent_config = AgentConfig() @@ -244,6 +251,71 @@ async def _fake_state_creation(*args: Any, **kwargs: Any) -> SimpleNamespace: assert final.prediction == "Lyon" +@pytest.mark.asyncio +async def test_fail_count_accumulates_one_per_failed_action( + monkeypatch: pytest.MonkeyPatch, tmp_log_dir: Path, base_action, base_state +) -> None: + """Each failed action must raise the global fail_count by exactly 1. + + Regression for the double-counting bug: the parent used to pass its + cumulative ``fail_count`` into every state_creation sub-workflow AND add the + (already-cumulative) returned value back with ``+=``. With ``fail_limit=3`` + that tripped termination after 2 failed actions instead of 3. Here the mock + reproduces the real sub-workflow contract -- it returns the passed-in + ``fail_count`` incremented by 1 -- so the bug would surface as an early + termination. + """ + _, run_context = _make_agent(tmp_log_dir, fail_limit=3, max_workers=1) + agent = ExposedDeepSearchAgent() + actions = [ + base_action.model_copy( + update={"id": f"action-{i}", "proposal": ActionProposal(direction=f"d{i}", score=0.5)} + ) + for i in range(5) + ] + state_creation_calls: list[int] = [] + + async def _fake_run_workflow(*, workflow: str, inputs: dict) -> SimpleNamespace: + if workflow == "init_state_1": + return SimpleNamespace( + result={"init_state": base_state, "total_input_tokens": 0, "total_output_tokens": 0} + ) + if workflow == "find_action_1": + return SimpleNamespace( + result={"actions": actions, "total_input_tokens": 0, "total_output_tokens": 0} + ) + if workflow == "state_creation_1": + # Mirror algorithm/search_nodes/utils.py: the sub-workflow increments + # the fail_count it was handed and echoes it back inside ``config``. + passed_in = inputs.get("fail_count", 0) + state_creation_calls.append(passed_in) + return SimpleNamespace( + result={ + "result": None, + "config": {"fail_count": passed_in + 1}, + "total_input_tokens": 0, + "total_output_tokens": 0, + } + ) + raise AssertionError(workflow) + + monkeypatch.setattr( + "openjiuwen_deepsearch.framework.openjiuwen.agent.workflow.Runner.run_workflow", + _fake_run_workflow, + ) + + final = await agent.run_internal(run_context) + + assert final.termination == "fail_limit" + # Exactly 3 failed actions are needed to reach fail_limit=3 (one increment + # each). The double-counting bug would terminate after only 2. + assert len(state_creation_calls) == 3 + assert run_context.fail_count == 3 + # The parent must hand a per-action delta base of 0 to each sub-workflow, + # not its running cumulative total. + assert state_creation_calls == [0, 0, 0] + + @pytest.mark.asyncio async def test_answer_writes_final_result_json( monkeypatch: pytest.MonkeyPatch, tmp_log_dir: Path, base_action, base_state