Ok, so I found a solution which should also work in case a class is not under my control. This solution only targets the AttributeError but should be extendable in case other Errors need to be caught.
We still have the same test function and the same Dummy class
def test(first, second):
    print("My name is " + first.age + " and I am here with " + second.age)
class Dummy(object):
    def __init__(self):
        pass
We can use a Proxy object to wrap each value we pass to the test function. 
This proxy object records if it sees an AttributeError by setting the _had_exception flag.
class Proxy(object):
    def __init__(self, object_a):
        self._object_a = object_a
        self._had_exception: bool = False
    def __getattribute__(self, name):
        if name == "_had_exception":
            return object.__getattribute__(self, name)
        obj = object.__getattribute__(self, '_object_a')
        try:
            return getattr(obj, name)
        except AttributeError as e:
            # Flag this object as a cause for an exception
            self._had_exception = True 
            raise e
And the call to the function looks as follows
d = Dummy()
d.__setattr__("age", "25")
p1 = Proxy(d)
p2 = Proxy(Dummy())
try:
    test(p1, p2)
except AttributeError as e:
    # Get the local variables from when the Error happened
    locals = e.__traceback__.tb_next.tb_frame.f_locals
    offender_names = []
    # Check if one of the local items is the same 
    # as one of our inputs that caused an Error
    for key, val in locals.items():
        if p1._had_exception:
            if p1 is val:
                offender_names.append(key)
        if p2._had_exception:
            if p2 is val:
                offender_names.append(key)
    print(offender_names) # ['second']
The end result is a list with all local variable names -- used in the called function -- which correspond to our wrapped inputs, that caused an exception.