feat: add tool-call fallback and resilient batch exports

This commit is contained in:
chenmingxuan committed 2026-09-29 19:53:20 +08:00
1 parent ab066733b8
commit d31eb2a19d
32 files changed
+538 -72

No files matched your search

+36 -3
View File
@@ -107,23 +107,56 @@ async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatc
@pytest.mark.parametrize("limit", [1, 3])
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
fallback = Mock()
get_fallback = Mock(return_value=fallback)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
node, state = operator
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
await node._invoke_llm_loop(Mock(), [], [], [], state)
assert invoke.await_count == limit
assert invoke.await_count == limit + 3
get_fallback.assert_called_once_with(ctx=node.ctx, name="operator", use_fallback=True)
assert invoke.call_args_list[-1].args[0] is fallback.bind_tools.return_value
@pytest.mark.asyncio
async def test_operator_fallback_is_scoped_to_current_step(operator, monkeypatch):
node, state = operator
primary = Mock()
fallback = Mock()
get_fallback = Mock(return_value=fallback)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
invoke = AsyncMock(side_effect=[AIMessage(content="no tool")] * 3 + [click(), click()])
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
first = await node._invoke_llm_loop(primary, [], [], [], state)
second = await node._invoke_llm_loop(primary, [], [], [], state)
assert first[0] and second[0]
assert get_fallback.call_count == 1
assert [call.args[0] for call in invoke.call_args_list] == [
primary.bind_tools.return_value,
primary.bind_tools.return_value,
primary.bind_tools.return_value,
fallback.bind_tools.return_value,
primary.bind_tools.return_value,
]
@pytest.mark.asyncio
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
invoke = AsyncMock(side_effect=[click("missing"), AIMessage(content="Retry."), click("14")])
invoke = AsyncMock(
side_effect=[click("missing"), AIMessage(content="Retry."), click("14")] + [click("14")] * 3
)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock()))
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
node, state = operator
messages = []
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
await node._invoke_llm_loop(Mock(), messages, [], [], state)
assert invoke.await_count == 3
assert invoke.await_count == 6
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)