I was trying to implement Transfer to human tool c...
# agents
c
I was trying to implement Transfer to human tool call in my agent, but the agent keeps calling the tool repeatedly in case of delay or failure. How do I stop this?
When my agent call the tool, it attempts a transfer, but after the tool call is complete, in case of failure/ successful transfer, the tool is called again. This redials the same person. Sometimes, it creates a separate room with the person and agent only, and starts a new discussion with him. What is happening here? How can I stop this? cc: @refined-appointment-81829
l
c
Copy code
@function_tool
    async def transfer_call(self, context: RunContext, reason: str | None = None):
        """
        Transfer the conversation to a human agent when appropriate.
        Call this function when:
        - Direct requests: "I want to speak to a human", "Connect me with a representative", "Transfer me to an agent"
        - Technical limitations: The user needs something beyond your capabilities
        - Complex issues: Problems requiring human judgment or access to systems beyond your reach

        Before calling this function, try to assist the user first if the request is within your capabilities.

        Do not call this function when:
        - You can answer basic information requests yourself
        - You can complete simple tasks within your capabilities
        - The user hasn't explicitly requested human assistance for complex issues

        EXAMPLE FLOWS:

        Example 1 (explicit request):
        User: "I want to talk to a real person instead of an AI. Please connect me to a human agent."
        Assistant: "I understand you'd prefer to speak with a human agent. I'm arranging that connection for you now, and a human representative will assist you shortly."
        [transfer_to_number function called]

        Example 2 (technical limitation):
        User: "I need to change my billing address and payment method for my order #12345."
        Assistant: "I understand you need to update your billing information. Since this requires accessing your account details, I'll connect you with a human representative who can help you with that change safely."
        [transfer_to_number function called]

        Example 3 (DO NOT call):
        User: "What's your refund policy?"
        Assistant: "Our standard refund policy allows returns within 30 days of purchase with a receipt. Would you like more specific information about a particular product or situation?"
        """

        transfer_to = self.dial_info["transfer_to"]
        if not transfer_to:
            await context.session.say("I'm sorry, I cannot transfer the call right now. I can help schedule a callback for you.")
            # self._closing_task = asyncio.create_task(self.session.aclose())
            raise Exception("human agent number not provided")

        <http://logger.info|logger.info>(f"transferring call to {transfer_to}")

        # let the message play fully before transferring
        await context.session.say("I'll be transferring you to my team. Please hold on while I connect you.")
        job_ctx = get_job_context()
        try:
            self.human_transfer_vars["attempted"] = True
            self.human_transfer_vars["attempted_at"] = datetime.now(timezone.utc)
            await job_ctx.api.sip.create_sip_participant(
            api.CreateSIPParticipantRequest(
                    room_name=job_ctx.room.name,
                    sip_trunk_id=self.dial_info["sip_trunk_id"],
                    sip_call_to=transfer_to,
                    participant_identity=transfer_to,
                    # function blocks until user answers the call, or if the call fails
                    wait_until_answered=True,
                )
            )

            participant = await job_ctx.wait_for_participant(identity=transfer_to)
            <http://logger.info|logger.info>(f"participant joined: {participant.identity}")
            self.human_transfer_vars["handover_at"] = datetime.now(timezone.utc)

            await job_ctx.api.room.remove_participant(api.RoomParticipantIdentity(
                room=job_ctx.room.name,
                identity=job_ctx.room.local_participant.identity
            ))
            <http://logger.info|logger.info>(f"Agent disconnected")
            self.human_transfer_vars["agent_disconnected_at"] = datetime.now(timezone.utc)
            self.human_transfer_vars["attempt_status"] = "success"
            <http://logger.info|logger.info>(f"transferred call to {transfer_to}")
        except Exception as e:
            logger.error(f"error transferring call to {transfer_to}: {e}")
            self.human_transfer_vars["error_message"] = str(e)
            self.human_transfer_vars["attempt_status"] = "failed"
            await context.session.say("I'm sorry, I cannot transfer the call right now. I will schedule a callback for you.")
            self._closing_task = asyncio.create_task(self.session.aclose())
yes, i did see that example. I did some changes as per my use case and it was working. But recently, the agent has been calling the tool twice each time.
l
But recently, the agent has been calling the tool twice each time.
Was there anything that coincided with the start of the failures? An update to the code? An upgrade to a new package version? A redeploy which might have resulted in pulling in an updated package? As an absolute fail-safe you could test whether the tool had already been called by storing some flag in userData, https://docs.livekit.io/agents/build/agents-handoffs/#passing-state, and checking for that in the subsequent function call - but ideally that shouldn't be necessary.
c
The code changes: 1.
Copy code
await job_ctx.api.room.remove_participant(api.RoomParticipantIdentity(
                room=job_ctx.room.name,
                identity=job_ctx.room.local_participant.identity
            ))
            <http://logger.info|logger.info>(f"Agent disconnected")
updated to
Copy code
await self.session.aclose()
this let me have control over the room events even after agent disconnection.
Copy code
except Exception as e:
            logger.error(f"error transferring call to {transfer_to}: {e}")
            self.human_transfer_vars["error_message"] = str(e)
            self.human_transfer_vars["attempt_status"] = "failed"
            await context.session.say("I'm sorry, I cannot transfer the call right now. I will schedule a callback for you.")
            self._closing_task = asyncio.create_task(self.session.aclose())
Also I had a close session task in my exception block, which was later removed to let the agent remain in the conversation in case of failure to add a human agent.
My hypothesis from the tests: 1. The agent calls the tool again if the wait time for the tool execution exceeds a certain time barrier. a. adding _`wait_until_answered=True`_ delays the execution until addition of the human, which is why maybe the agent is calling it again?
Is there any way I can have a check on if the sip participant joined or not and keep the agent in the conversation without repeated tool calls?
l
I'm not sure but I think you're on the right track with your hypothesis. I tried asking AI if it had any ideas about the duplicate function calls but the answers were all variations of what I said above (adding some kind of flag to check if the transfer was already in progress), so I didn't have a better suggestion.
I wasn't aware of this until last night, but we also have a warm transfer guide as part of our docs showing best practices: https://docs.livekit.io/sip/transfer-warm/
c
I have been through the doc, i understand the implementation. My ask is why is the participant joined event not in sync with the actual event of the sip participant answering the call. • When the call is happening, i get participant joined event, when the ring starts, not when the user answers the call. Is there any reason for mapping the event as such? This creates a participant joined event even if the user doesn't answer hence not successfully connected to the room.
l
It's a fair question, and I can't speak authoritatively why it was designed that way - I guess it does provide more flexibility, and allows you to distinguish between a call that is ringing, and a call that is answered.
r
SIP call state is managed independent from the participant session (which is actually the SIP session) https://docs.livekit.io/recipes/sip_lifecycle/
c
Thank you so much, this solves my troubles. Will explore this implementation.