Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions livekit-agents/livekit/agents/beta/workflows/credit_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
{modality_specific}
If the user refuses to provide a credit card number, call decline_card_capture().
If the user wishes to start over the credit card collection process, call restart_card_collection().
Always explicitly invoke a tool when applicable. Do not simulate tool usage, and never state that a value was recorded or confirmed unless the corresponding tool call succeeded.
Avoid listing out questions with bullet points or numbers, use a natural conversational tone.
Never repeat any sensitive information, such as the user's credit card number, back to the user.
{confirmation_instructions}
Expand All @@ -46,6 +47,7 @@
{modality_specific}
If the user refuses to provide a code, call decline_card_capture().
If the user wishes to start over the card collection process, call restart_card_collection().
Always explicitly invoke a tool when applicable. Do not simulate tool usage, and never state that a value was recorded or confirmed unless the corresponding tool call succeeded.
Avoid listing out questions with bullet points or numbers, use a natural conversational tone.
Never repeat any sensitive information, such as the user's security code, back to the user.
{confirmation_instructions}
Expand All @@ -67,6 +69,7 @@
{modality_specific}
If the user refuses to provide a date, call decline_card_capture().
If the user wishes to start over the card collection process, call restart_card_collection().
Always explicitly invoke a tool when applicable. Do not simulate tool usage, and never state that a value was recorded or confirmed unless the corresponding tool call succeeded.
Avoid listing out questions with bullet points or numbers, use a natural conversational tone.
Never repeat any sensitive information, such as the user's expiration date, back to the user.
{confirmation_instructions}
Expand Down Expand Up @@ -229,6 +232,19 @@ async def _update_card_number_impl(self, context: RunContext, card_number: str)
instructions="The length of the card number is invalid, ask the user to repeat their card number."
)
return None
elif (
self._card_number
and card_number == self._card_number
and self._confirmation_required(context)
):
# The user repeated the same number for confirmation, but the LLM
# routed it back here instead of the confirm tool. Re-arming the
# confirmation would stall the task (the model then claims the
# number is confirmed without calling any tool), so redirect it.
raise ToolError(
"This card number is already recorded and awaiting confirmation. "
"Call `confirm_card_number` with the repeated number instead."
)
else:
self._card_number = card_number

Expand All @@ -253,6 +269,8 @@ async def _update_card_number_impl(self, context: RunContext, card_number: str)
return (
"The card number has been updated.\n"
"Ask them to repeat the number, do not repeat the number back to them.\n"
"If the user already repeated the card number earlier in the conversation, call "
"`confirm_card_number` with that repeated card number now instead of asking again.\n"
)

def _build_confirm_tool(self, *, card_number: str) -> llm.FunctionTool:
Expand Down Expand Up @@ -383,6 +401,17 @@ async def _update_security_code_impl(
instructions="The security code's length is invalid, ask the user to repeat or to provide a new card and start over."
)
return None
elif (
self._security_code
and stripped == self._security_code
and self._confirmation_required(context)
):
# See _update_card_number_impl: redirect a repeated identical value
# to the confirm tool instead of silently re-arming confirmation.
raise ToolError(
"This security code is already recorded and awaiting confirmation. "
"Call `confirm_security_code` with the repeated code instead."
)
else:
self._security_code = stripped

Expand All @@ -398,8 +427,11 @@ async def _update_security_code_impl(

return (
"The security code has been updated.\n"
"If the user already repeated the security code earlier in the conversation, call "
"`confirm_security_code` with that repeated security code now instead of asking again.\n"
"Do not repeat the security code back to the user, ask them to repeat themselves.\n"
"Call `confirm_security_code` once the user confirms, do not call it preemptively.\n"
"Call `confirm_security_code` once the user has repeated the code. Do not call it "
"preemptively, and do not treat the code as confirmed until that call succeeds.\n"
)

def _build_confirm_tool(self, *, security_code: str) -> llm.FunctionTool:
Expand Down Expand Up @@ -518,6 +550,17 @@ async def _update_expiration_date_impl(
instructions="The expiration date is in the past, the card is expired. Ask the user to provide another card."
)
return None
elif (
self._expiration_date
and f"{expiration_month:02d}/{expiration_year:02d}" == self._expiration_date
and self._confirmation_required(context)
):
# See _update_card_number_impl: redirect a repeated identical value
# to the confirm tool instead of silently re-arming confirmation.
raise ToolError(
"This expiration date is already recorded and awaiting confirmation. "
"Call `confirm_expiration_date` with the repeated date instead."
)
else:
self._expiration_date = f"{expiration_month:02d}/{expiration_year:02d}"

Expand All @@ -535,8 +578,11 @@ async def _update_expiration_date_impl(

return (
"The expiration date has been updated.\n"
"If the user already repeated the expiration date earlier in the conversation, call "
"`confirm_expiration_date` with that repeated expiration date now instead of asking again.\n"
"Do not repeat the expiration date back to the user, ask them to repeat themselves.\n"
"Call `confirm_expiration_date` once the user confirms, do not call it preemptively.\n"
"Call `confirm_expiration_date` once the user has repeated the date. Do not call it "
"preemptively, and do not treat the date as confirmed until that call succeeds.\n"
)

def _build_confirm_tool(
Expand Down
67 changes: 67 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,70 @@ async def test_get_dtmf_sip_event_with_confirmation() -> None:
)

assert result.final_output.user_input == "1 2 3 4 5 6 7 8 9 0"


def _audio_run_context() -> object:
# Minimal stand-in for RunContext: the update_* impls only read
# ctx.speech_handle.input_details.modality (via _confirmation_required).
from types import SimpleNamespace

return SimpleNamespace(
speech_handle=SimpleNamespace(input_details=SimpleNamespace(modality="audio"))
)


@pytest.mark.asyncio
async def test_credit_card_update_redirects_to_confirm_when_pending() -> None:
# A repeated identical value routed to update_* (instead of confirm_*)
# must raise a ToolError pointing at the confirm tool - re-arming the
# confirmation used to stall the task (AGT-3139).
from livekit.agents.beta.workflows.credit_card import (
GetCardNumberTask,
GetExpirationDateTask,
GetSecurityCodeTask,
)

ctx = _audio_run_context()

number_task = GetCardNumberTask()
assert await number_task._update_card_number_impl(ctx, "4242 4242 4242 4242")
with pytest.raises(ToolError, match="confirm_card_number"):
await number_task._update_card_number_impl(ctx, "4242424242424242")

code_task = GetSecurityCodeTask()
assert await code_task._update_security_code_impl(ctx, "123")
with pytest.raises(ToolError, match="confirm_security_code"):
await code_task._update_security_code_impl(ctx, "123")

date_task = GetExpirationDateTask()
assert await date_task._update_expiration_date_impl(ctx, 4, 99)
with pytest.raises(ToolError, match="confirm_expiration_date"):
await date_task._update_expiration_date_impl(ctx, 4, 99)

# A *different* value is a correction, not a confirmation: it must
# re-arm confirmation with the new value instead of raising.
assert await code_task._update_security_code_impl(ctx, "456")


@pytest.mark.asyncio
async def test_collect_security_code_repeat_back_completes() -> None:
# End-to-end: with audio-modality confirmation, repeating the code must
# complete the task in that same turn, whichever tool the LLM routes
# the repeat to (AGT-3139 stall regression).
from livekit.agents.beta.workflows.credit_card import (
GetSecurityCodeResult,
GetSecurityCodeTask,
)

async with _llm_model() as llm, AgentSession(llm=llm) as sess:
await sess.start(GetSecurityCodeTask())

await sess.run(user_input="The security code is 123", input_modality="audio")

result = await sess.run(
user_input="1 2 3",
output_type=GetSecurityCodeResult,
input_modality="audio",
)

assert result.final_output.security_code == "123"