项目文件夹

文件
2026-07-13 13:39:38 +08:00

692 行
24 KiB
Python

import base64
import os
from typing import Any
import pytest
from livekit.agents import inference
from livekit.agents.llm import AgentHandoff, ChatContext, FunctionCall, FunctionCallOutput, utils
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from .fake_llm import FakeLLM, FakeLLMResponse
pytestmark = [pytest.mark.unit, pytest.mark.concurrent]
_IMAGE_BYTES = b"fake image bytes"
def ai_function1(a: int, b: str = "default") -> None:
"""
This is a test function
Args:
a: First argument
b: Second argument
"""
pass
def skip_if_no_credentials():
required_vars = ["LIVEKIT_API_KEY", "LIVEKIT_API_SECRET"]
missing = [var for var in required_vars if not os.getenv(var)]
return pytest.mark.skipif(
bool(missing), reason=f"Missing environment variables: {', '.join(missing)}"
)
def test_args_model():
from docstring_parser import parse_from_object
docstring = parse_from_object(ai_function1)
print(docstring.description)
model = utils.function_arguments_to_pydantic_model(ai_function1)
print(model.model_json_schema())
def test_dict():
from livekit import rtc
from livekit.agents.beta import Instructions
from livekit.agents.llm import ChatContext, ImageContent
chat_ctx = ChatContext()
chat_ctx.add_message(
role="system",
content=Instructions(
"You are a helpful assistant in audio mode.",
text="You are a helpful assistant in text mode.",
),
)
chat_ctx.add_message(
role="user",
content="Hello, world!",
)
chat_ctx.add_message(
role="assistant",
content="Hello, world!",
)
chat_ctx.add_message(
role="user",
content=[
ImageContent(
image=rtc.VideoFrame(64, 64, rtc.VideoBufferType.RGB24, b"0" * 64 * 64 * 3)
)
],
)
print(chat_ctx.to_dict())
print(chat_ctx.items)
print(ChatContext.from_dict(chat_ctx.to_dict()).items)
@pytest.mark.parametrize(
("mime_type", "expected_format"),
[
("image/jpeg", "jpeg"),
("image/png", "png"),
("image/gif", "gif"),
("image/webp", "webp"),
],
)
def test_aws_image_content_uses_serialized_image_format(mime_type: str, expected_format: str):
from livekit.agents.llm import ChatContext, ImageContent
encoded = base64.b64encode(_IMAGE_BYTES).decode("utf-8")
chat_ctx = ChatContext.empty()
chat_ctx.add_message(
role="user",
content=[ImageContent(image=f"data:{mime_type};base64,{encoded}")],
)
messages, _ = chat_ctx.to_provider_format(format="aws")
image = messages[0]["content"][0]["image"]
assert image["format"] == expected_format
assert image["source"]["bytes"] == _IMAGE_BYTES
def test_aws_image_content_rejects_external_urls():
from livekit.agents.llm import ChatContext, ImageContent
chat_ctx = ChatContext.empty()
chat_ctx.add_message(
role="user",
content=[ImageContent(image="https://example.com/image.png", mime_type="image/png")],
)
with pytest.raises(ValueError, match="external_url is not supported by AWS Bedrock"):
chat_ctx.to_provider_format(format="aws")
def test_chat_ctx_can_be_serialized_and_deserialized_with_defaults():
from livekit.agents.llm import AgentHandoff, ChatContext, ChatMessage
items = [
AgentHandoff(new_agent_id="default_agent", old_agent_id=None),
ChatMessage(role="user", content=["Hello, world!"]),
ChatMessage(role="assistant", content=["Hi there!"]),
]
chat_ctx = ChatContext(items)
assert chat_ctx.is_equivalent(ChatContext.from_dict(chat_ctx.to_dict()))
@skip_if_no_credentials()
async def test_summarize():
from livekit.agents import ChatContext
chat_ctx = ChatContext()
chat_ctx.add_message(
role="system",
content=(
"You are SupportGPT, a customer service agent for Acme Audio. "
"Gather identifying info first, then troubleshoot. "
"Only promise replacements if the device is under warranty. "
"Use the provided tools for order lookup, warranty checks, and RMA creation. "
"If a return is required, hand off to ReturnsAgent for shipping label logistics."
),
)
chat_ctx.add_message(
role="user",
content=(
"Hi, I need help with an order I placed last week. The earbuds I got "
"keep disconnecting and the left side sounds crackly."
),
)
chat_ctx.add_message(
role="assistant",
content=(
"I can help with that! First, could you share your full name and the email "
"you used at checkout, so I can locate your order?"
),
)
chat_ctx.add_message(
role="user", content=("Sure—I'm Maya Chen, and I used maya.chen+shop@gmail.com.")
)
chat_ctx.add_message(
role="assistant",
content=("Thanks, Maya. Do you also have the order number and approximate purchase date?"),
)
chat_ctx.add_message(role="user", content=("Order #LK-4821936. I bought them on October 7."))
chat_ctx.items.append(
FunctionCall(
name="lookup_order",
call_id="call_lookup_order_1",
arguments='{"order_number": "LK-4821936", "customer_email": "maya.chen+shop@gmail.com"}',
)
)
chat_ctx.items.append(
FunctionCallOutput(
name="lookup_order",
call_id="call_lookup_order_1",
output=(
'{"order_number":"LK-4821936","customer_name":"Maya Chen","'
'items":[{"sku":"AC-EBD-PRO","name":"Acme Buds Pro","qty":1}],'
'"purchase_date":"2025-10-07","status":"delivered","serial":"ACB-PRO-7F29D4"}'
),
is_error=False,
)
)
chat_ctx.add_message(
role="assistant",
content=(
"I found your order LK-4821936 for Acme Buds Pro, delivered October 8. "
"To check warranty and next steps, which device are you pairing with and what OS version?"
),
)
chat_ctx.add_message(role="user", content="iPhone 14 Pro, iOS 18.0.1.")
chat_ctx.add_message(
role="assistant",
content=(
"Thanks. Have you tried any troubleshooting—resetting the buds, forgetting/re-pairing Bluetooth, "
"or testing another device?"
),
)
chat_ctx.add_message(
role="user",
content=(
"I tried forgetting and re-pairing twice. I also tested on my iPad and the left ear still crackles."
),
)
chat_ctx.add_message(
role="assistant",
content=("Understood. Any visible damage or signs of moisture? And when did it start?"),
)
chat_ctx.add_message(
role="user",
content=("No damage or moisture. It started the day after I received them—October 9."),
)
chat_ctx.items.append(
FunctionCall(
name="check_warranty",
call_id="call_check_warranty_1",
arguments='{"serial":"ACB-PRO-7F29D4","purchase_date":"2025-10-07"}',
)
)
chat_ctx.items.append(
FunctionCallOutput(
name="check_warranty",
call_id="call_check_warranty_1",
output='{"eligible":true,"warranty_expires":"2026-10-07"}',
is_error=False,
)
)
chat_ctx.add_message(
role="assistant",
content=(
"This appears to be a hardware defect and you’re under warranty until 2026-10-07. "
"I can set up a free replacement. Could you confirm your shipping address and a contact number?"
),
)
chat_ctx.add_message(
role="user",
content=("Ship to 2150 Grove St, Apt 4B, Oakland, CA 94612. Phone is (510) 555-0136."),
)
chat_ctx.items.append(
FunctionCall(
name="create_rma",
call_id="call_create_rma_1",
arguments=(
'{"order_number":"LK-4821936","serial":"ACB-PRO-7F29D4","reason":"left bud crackling / disconnects",'
'"customer":{"name":"Maya Chen","email":"maya.chen+shop@gmail.com","phone":"(510) 555-0136","'
'address":"2150 Grove St, Apt 4B, Oakland, CA 94612"}}'
),
)
)
chat_ctx.items.append(
FunctionCallOutput(
name="create_rma",
call_id="call_create_rma_1",
output='{"rma_id":"RMA-90721","replacement_eta_days":2}',
is_error=False,
)
)
chat_ctx.items.append(AgentHandoff(old_agent_id="SupportGPT", new_agent_id="ReturnsAgent"))
chat_ctx.items.append(
FunctionCall(
name="generate_return_label",
call_id="call_label_1",
arguments='{"rma_id":"RMA-90721","email":"maya.chen+shop@gmail.com"}',
)
)
chat_ctx.items.append(
FunctionCallOutput(
name="generate_return_label",
call_id="call_label_1",
output='{"label_url":"https://example.invalid/label/RMA-90721","due_in_days":14}',
is_error=False,
)
)
chat_ctx.add_message(
role="assistant",
content=(
"All set! I’ve created RMA #RMA-90721 linked to order LK-4821936. "
"You’ll receive the prepaid return label and instructions at maya.chen+shop@gmail.com. "
"Please ship the defective pair within 14 days; your replacement will ship within 48 hours."
),
)
import json
async with inference.LLM(model="openai/gpt-4.1-mini") as llm:
summary = await chat_ctx._summarize(llm, keep_last_turns=1)
print("\n=== Summary ===\n")
print(json.dumps(summary.to_dict(), indent=2))
# --- summarize unit tests (no credentials required) ---
class _FixedSummaryLLM(FakeLLM):
"""FakeLLM that returns a fixed summary string for any input."""
def __init__(self, summary: str) -> None:
super().__init__()
self._summary = summary
def chat(
self,
*,
chat_ctx: ChatContext,
tools: Any = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: Any = NOT_GIVEN,
extra_kwargs: Any = NOT_GIVEN,
):
last_msg = chat_ctx.items[-1]
input_text = last_msg.text_content
self._fake_response_map[input_text] = FakeLLMResponse(
input=input_text,
content=self._summary,
ttft=0.0,
duration=0.0,
)
return super().chat(
chat_ctx=chat_ctx,
tools=tools,
conn_options=conn_options,
)
CANNED_SUMMARY = "User asked about earbuds. Agent resolved the issue."
def _build_conversation_ctx() -> ChatContext:
"""Build a ChatContext with system, user/assistant pairs, and interleaved tool calls."""
from livekit.agents.llm import ChatContext
ctx = ChatContext()
ctx.add_message(role="system", content="You are a helpful assistant.")
ctx.add_message(role="user", content="Hi, my earbuds are broken.")
ctx.add_message(role="assistant", content="Can you share your order number?")
ctx.add_message(role="user", content="Order #123.")
ctx.items.append(FunctionCall(name="lookup_order", call_id="c1", arguments='{"order": "123"}'))
ctx.items.append(
FunctionCallOutput(
name="lookup_order", call_id="c1", output='{"status":"delivered"}', is_error=False
)
)
ctx.add_message(role="assistant", content="Found your order. Let me check warranty.")
ctx.add_message(role="user", content="Thanks.")
ctx.add_message(role="assistant", content="You are under warranty.")
return ctx
@pytest.mark.asyncio
async def test_summarize_head_tail_split_basic():
from livekit.agents.llm import ChatContext
ctx = ChatContext()
ctx.add_message(role="system", content="System prompt.")
ctx.add_message(role="user", content="msg1")
ctx.add_message(role="assistant", content="reply1")
ctx.add_message(role="user", content="msg2")
ctx.add_message(role="assistant", content="reply2")
ctx.add_message(role="user", content="msg3")
ctx.add_message(role="assistant", content="reply3")
llm = _FixedSummaryLLM(CANNED_SUMMARY)
result = await ctx._summarize(llm, keep_last_turns=1)
tail_msgs = [
it
for it in result.items
if it.type == "message"
and it.role in ("user", "assistant")
and not it.extra.get("is_summary")
]
assert len(tail_msgs) == 2
assert tail_msgs[0].text_content == "msg3"
assert tail_msgs[1].text_content == "reply3"
summaries = [it for it in result.items if it.type == "message" and it.extra.get("is_summary")]
assert len(summaries) == 1
assert CANNED_SUMMARY in summaries[0].text_content
system_msgs = [it for it in result.items if it.type == "message" and it.role == "system"]
assert len(system_msgs) == 1
@pytest.mark.asyncio
async def test_summarize_head_tail_split_with_renderables():
ctx = _build_conversation_ctx()
llm = _FixedSummaryLLM(CANNED_SUMMARY)
result = await ctx._summarize(llm, keep_last_turns=2)
# With keep_last_turns=2, the backward walk counts 4 ChatMessages:
# "You are under warranty." (1), "Thanks." (2),
# "Found your order..." (3), "Order #123." (4) ← split here
# The FunctionCall + FunctionCallOutput between "Order #123." and
# "Found your order..." fall inside the tail and must be preserved.
tail_msgs = [
it
for it in result.items
if it.type == "message"
and it.role in ("user", "assistant")
and not it.extra.get("is_summary")
]
assert len(tail_msgs) == 4
assert tail_msgs[0].text_content == "Order #123."
assert tail_msgs[1].text_content == "Found your order. Let me check warranty."
assert tail_msgs[2].text_content == "Thanks."
assert tail_msgs[3].text_content == "You are under warranty."
fn_items = [it for it in result.items if it.type in ("function_call", "function_call_output")]
assert len(fn_items) == 2
summaries = [it for it in result.items if it.type == "message" and it.extra.get("is_summary")]
assert len(summaries) == 1
@pytest.mark.asyncio
async def test_summarize_keep_last_turns_zero():
ctx = _build_conversation_ctx()
llm = _FixedSummaryLLM(CANNED_SUMMARY)
result = await ctx._summarize(llm, keep_last_turns=0)
raw_msgs = [
it
for it in result.items
if it.type == "message"
and it.role in ("user", "assistant")
and not it.extra.get("is_summary")
]
assert len(raw_msgs) == 0
fn_items = [it for it in result.items if it.type in ("function_call", "function_call_output")]
assert len(fn_items) == 0
summaries = [it for it in result.items if it.type == "message" and it.extra.get("is_summary")]
assert len(summaries) == 1
system_msgs = [it for it in result.items if it.type == "message" and it.role == "system"]
assert len(system_msgs) == 1
@pytest.mark.asyncio
async def test_summarize_preserves_structural_items():
from livekit.agents.llm import ChatContext
ctx = ChatContext()
ctx.add_message(role="system", content="System prompt.")
ctx.add_message(role="user", content="Hello.")
ctx.add_message(role="assistant", content="Hi there.")
ctx.items.append(AgentHandoff(old_agent_id="AgentA", new_agent_id="AgentB"))
ctx.add_message(role="user", content="Transfer me.")
ctx.add_message(role="assistant", content="Done.")
ctx.add_message(role="user", content="Thanks.")
ctx.add_message(role="assistant", content="Welcome.")
llm = _FixedSummaryLLM(CANNED_SUMMARY)
result = await ctx._summarize(llm, keep_last_turns=1)
# system message preserved
system_msgs = [it for it in result.items if it.type == "message" and it.role == "system"]
assert len(system_msgs) == 1
# agent handoff preserved
handoffs = [it for it in result.items if it.type == "agent_handoff"]
assert len(handoffs) == 1
assert handoffs[0].old_agent_id == "AgentA"
assert handoffs[0].new_agent_id == "AgentB"
@pytest.mark.asyncio
async def test_summarize_skips_when_not_enough_messages():
from livekit.agents.llm import ChatContext
ctx = ChatContext()
ctx.add_message(role="system", content="System prompt.")
ctx.add_message(role="user", content="Hello.")
ctx.add_message(role="assistant", content="Hi there.")
original_items = list(ctx.items)
llm = _FixedSummaryLLM(CANNED_SUMMARY)
result = await ctx._summarize(llm, keep_last_turns=1)
# budget covers all messages, so nothing to summarize — early return
assert len(result.items) == len(original_items)
for a, b in zip(result.items, original_items, strict=True):
assert a.id == b.id
# --- truncate tests ---
def _make_ctx(*roles: str):
"""Build a ChatContext with messages of the given roles."""
from livekit.agents.llm import ChatContext
ctx = ChatContext()
for role in roles:
if role == "function_call":
ctx.items.append(FunctionCall(name="fn", call_id="c1", arguments="{}"))
elif role == "function_call_output":
ctx.items.append(
FunctionCallOutput(name="fn", call_id="c1", output="{}", is_error=False)
)
else:
ctx.add_message(role=role, content=f"msg-{role}")
return ctx
def test_truncate_noop_when_under_limit():
ctx = _make_ctx("system", "user", "assistant")
original_ids = [item.id for item in ctx.items]
ctx.truncate(max_items=5)
assert [item.id for item in ctx.items] == original_ids
def test_truncate_basic():
ctx = _make_ctx("user", "assistant", "user", "assistant")
ctx.truncate(max_items=2)
assert len(ctx.items) == 2
assert ctx.items[0].role == "user"
assert ctx.items[1].role == "assistant"
def test_truncate_preserves_system_instruction():
ctx = _make_ctx("system", "user", "assistant", "user", "assistant")
ctx.truncate(max_items=2)
# system should be re-inserted at the front
assert ctx.items[0].role == "system"
assert len(ctx.items) == 3 # system + last 2
def test_truncate_preserves_developer_instruction():
ctx = _make_ctx("developer", "user", "assistant", "user", "assistant")
ctx.truncate(max_items=2)
assert ctx.items[0].role == "developer"
assert len(ctx.items) == 3
def test_truncate_no_duplication():
"""When the instruction is already in the truncated tail, don't insert it again."""
ctx = _make_ctx("system", "user", "assistant")
ctx.truncate(max_items=3)
# system is already within the last 3 items, so no duplication
system_items = [item for item in ctx.items if getattr(item, "role", None) == "system"]
assert len(system_items) == 1
assert len(ctx.items) <= 3
def test_truncate_multiple_instructions():
"""Only the first instruction by position is preserved."""
from livekit.agents.llm import ChatContext
ctx = ChatContext()
ctx.add_message(role="system", content="first")
ctx.add_message(role="developer", content="second")
ctx.add_message(role="user", content="u1")
ctx.add_message(role="user", content="u2")
ctx.add_message(role="user", content="u3")
ctx.truncate(max_items=2)
# first instruction is the system msg
assert ctx.items[0].role == "system"
assert ctx.items[0].content == ["first"]
# --- remove tests ---
def test_remove_by_id():
ctx = _make_ctx("system", "user", "assistant")
target = ctx.items[1]
ctx.remove(target.id)
assert ctx.get_by_id(target.id) is None
def test_remove_by_item():
ctx = _make_ctx("user", "assistant", "user")
target = ctx.items[0]
ctx.remove(target)
assert len(ctx.items) == 2
def test_remove_nonexistent_raises():
ctx = _make_ctx("user", "assistant")
with pytest.raises(ValueError):
ctx.remove("nonexistent_id")
def test_instructions_serialization():
"""Instructions is resolved to str before storage in ChatMessage content."""
from livekit.agents.llm import ChatContext, ChatMessage
from livekit.agents.llm.chat_context import Instructions
# Instructions is no longer a valid ChatContent type; it must be resolved to str first
instr = Instructions("common text", audio="audio addition", text="text addition")
# str(instr) returns the common text
assert str(instr) == "common text"
# render resolves to a plain string
assert instr.render(modality="audio") == "common text\n\naudio addition"
assert instr.render(modality="text") == "common text\n\ntext addition"
# ChatMessage content must be str (Instructions is resolved before storage)
resolved = instr.render(modality="audio")
msg = ChatMessage(role="system", content=[resolved])
assert isinstance(msg.content[0], str)
assert msg.content[0] == "common text\n\naudio addition"
# add_message with Instructions resolves to str(instr) = common
ctx = ChatContext()
ctx.add_message(role="system", content=instr)
assert isinstance(ctx.items[0].content[0], str)
assert ctx.items[0].content[0] == "common text"
# to_dict / from_dict round-trip with resolved str content
ctx2 = ChatContext([ChatMessage(role="system", content=[resolved])])
data = ctx2.to_dict()
serialized = data["items"][0]["content"][0]
assert isinstance(serialized, str)
assert serialized == "common text\n\naudio addition"
restored = ChatContext.from_dict(ctx2.to_dict())
restored_content = restored.items[0].content[0]
assert isinstance(restored_content, str)
assert restored_content == "common text\n\naudio addition"
# Plain str content stays as str after round-trip
plain_ctx = ChatContext([ChatMessage(role="user", content=["hello"])])
plain_restored = ChatContext.from_dict(plain_ctx.to_dict())
assert type(plain_restored.items[0].content[0]) is str
def test_instructions_render():
"""render() returns a plain str combining common + modality-specific additions."""
from livekit.agents.llm.chat_context import Instructions
instr = Instructions(
"You are a helpful assistant.",
audio="Keep responses short for voice.",
text="Use markdown formatting.",
)
# render('audio') returns common + audio addition
resolved_audio = instr.render(modality="audio")
assert isinstance(resolved_audio, str)
assert resolved_audio == "You are a helpful assistant.\n\nKeep responses short for voice."
# render('text') returns common + text addition
resolved_text = instr.render(modality="text")
assert isinstance(resolved_text, str)
assert resolved_text == "You are a helpful assistant.\n\nUse markdown formatting."
# str(instr) returns just the common text
assert str(instr) == "You are a helpful assistant."
# Instructions without modality additions returns just common for both
common_only = Instructions("common only")
assert common_only.render(modality="audio") == "common only"
assert common_only.render(modality="text") == "common only"
# Instructions with only one modality addition
audio_only = Instructions("base", audio="audio extra")
assert audio_only.render(modality="audio") == "base\n\naudio extra"
assert audio_only.render(modality="text") == "base"
text_only = Instructions("base", text="text extra")
assert text_only.render(modality="audio") == "base"
assert text_only.render(modality="text") == "base\n\ntext extra"
# Empty common with additions
empty_common = Instructions("", audio="audio only", text="text only")
assert empty_common.render(modality="audio") == "audio only"
assert empty_common.render(modality="text") == "text only"