1015 lines
35 KiB
Python
1015 lines
35 KiB
Python
# Copyright 2023 LiveKit, Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from __future__ import annotations
|
|
|
|
import textwrap
|
|
import time
|
|
from collections.abc import Generator, Sequence
|
|
from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, overload
|
|
|
|
from pydantic import BaseModel, Field, PrivateAttr, TypeAdapter
|
|
from typing_extensions import TypedDict
|
|
|
|
from livekit import rtc
|
|
from livekit.protocol.agent_pb import agent_session as agent_pb
|
|
|
|
from .. import utils
|
|
from ..log import logger
|
|
from ..types import NOT_GIVEN, NotGivenOr
|
|
from ..utils.misc import is_given
|
|
from . import _provider_format
|
|
|
|
if TYPE_CHECKING:
|
|
from ..llm import LLM, Tool, Toolset
|
|
|
|
|
|
class Instructions:
|
|
"""Instructions with optional modality-specific additions.
|
|
|
|
Construction::
|
|
|
|
# Simple — same instructions for all modalities
|
|
Instructions("You are a helpful assistant.")
|
|
|
|
# With modality-specific additions
|
|
Instructions(
|
|
"You are a helpful assistant.",
|
|
audio="Keep responses short for voice.",
|
|
text="Use markdown formatting.",
|
|
)
|
|
|
|
Rendering::
|
|
|
|
instr.render() # → common text
|
|
instr.render(modality="audio") # → common + audio addition
|
|
instr.render(modality="text", name="Alex") # → common + text, with {name} filled
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
common: str = "",
|
|
*,
|
|
audio: str | None = None,
|
|
text: str | None = None,
|
|
) -> None:
|
|
self.common = common
|
|
self.audio = audio
|
|
self.text = text
|
|
|
|
def render(
|
|
self,
|
|
*,
|
|
modality: Literal["audio", "text"] | None = None,
|
|
data: dict[str, object] | None = None,
|
|
) -> str:
|
|
"""Render instructions to a plain string.
|
|
|
|
Args:
|
|
modality: If given, appends the modality-specific addition to the common text.
|
|
data: Template variables to fill. Missing placeholders log a warning
|
|
and are replaced with empty strings.
|
|
"""
|
|
parts = [self.common]
|
|
if modality is not None:
|
|
addition = self.audio if modality == "audio" else self.text
|
|
if addition:
|
|
parts.append(addition)
|
|
|
|
result = "\n\n".join(p for p in parts if p)
|
|
|
|
if data:
|
|
result = utils.misc.safe_render(result, data)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def resolve_template(template: str, **kwargs: object) -> Instructions:
|
|
"""Fill a template string, producing an ``Instructions`` with modality variants.
|
|
|
|
If any kwarg value is an ``Instructions`` object, its ``common``/``audio``/``text``
|
|
parts are substituted into the matching variant of the result. This is used by
|
|
workflow tasks to build modality-aware instructions from a single template.
|
|
"""
|
|
any_instructions = any(isinstance(v, Instructions) for v in kwargs.values())
|
|
if any_instructions:
|
|
common_kw: dict[str, object] = {
|
|
k: str(v) if isinstance(v, Instructions) else v for k, v in kwargs.items()
|
|
}
|
|
audio_kw: dict[str, object] = {
|
|
# an explicit "" removes the section; only None falls back to common
|
|
k: (v.audio if v.audio is not None else str(v))
|
|
if isinstance(v, Instructions)
|
|
else v
|
|
for k, v in kwargs.items()
|
|
}
|
|
text_kw: dict[str, object] = {
|
|
k: (v.text if v.text is not None else str(v)) if isinstance(v, Instructions) else v
|
|
for k, v in kwargs.items()
|
|
}
|
|
return Instructions(
|
|
common=utils.misc.safe_render(template, common_kw),
|
|
audio=utils.misc.safe_render(template, audio_kw),
|
|
text=utils.misc.safe_render(template, text_kw),
|
|
)
|
|
else:
|
|
rendered = utils.misc.safe_render(template, kwargs)
|
|
return Instructions(common=rendered)
|
|
|
|
def __str__(self) -> str:
|
|
return self.common
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Instructions({self.common!r})"
|
|
|
|
def __hash__(self) -> int:
|
|
return hash((self.common, self.audio, self.text))
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
if isinstance(other, Instructions):
|
|
return (
|
|
self.common == other.common
|
|
and self.audio == other.audio
|
|
and self.text == other.text
|
|
)
|
|
if isinstance(other, str):
|
|
return self.common == other
|
|
return NotImplemented
|
|
|
|
|
|
class ImageContent(BaseModel):
|
|
"""
|
|
ImageContent is used to input images into the ChatContext on supported LLM providers / plugins.
|
|
|
|
You may need to consult your LLM provider's documentation on supported URL types.
|
|
|
|
```python
|
|
# Pass a VideoFrame directly, which will be automatically converted to a JPEG data URL internally
|
|
async for event in rtc.VideoStream(video_track):
|
|
chat_image = ImageContent(image=event.frame)
|
|
# this instance is now available for your ChatContext
|
|
|
|
# Encode your VideoFrame yourself for more control, and pass the result as a data URL (see EncodeOptions for more details)
|
|
from livekit.agents.utils.images import encode, EncodeOptions, ResizeOptions
|
|
|
|
image_bytes = encode(
|
|
event.frame,
|
|
EncodeOptions(
|
|
format="PNG",
|
|
resize_options=ResizeOptions(width=512, height=512, strategy="scale_aspect_fit"),
|
|
),
|
|
)
|
|
chat_image = ImageContent(
|
|
image=f"data:image/png;base64,{base64.b64encode(image_bytes).decode('utf-8')}"
|
|
)
|
|
|
|
# With an external URL
|
|
chat_image = ImageContent(image="https://example.com/image.jpg")
|
|
```
|
|
"""
|
|
|
|
id: str = Field(default_factory=lambda: utils.shortuuid("img_"))
|
|
"""
|
|
Unique identifier for the image
|
|
"""
|
|
|
|
type: Literal["image_content"] = Field(default="image_content")
|
|
|
|
image: str | rtc.VideoFrame
|
|
"""
|
|
Either a string URL or a VideoFrame object
|
|
"""
|
|
inference_width: int | None = None
|
|
"""
|
|
Resizing parameter for rtc.VideoFrame inputs (ignored for URL images)
|
|
"""
|
|
inference_height: int | None = None
|
|
"""
|
|
Resizing parameter for rtc.VideoFrame inputs (ignored for URL images)
|
|
"""
|
|
inference_detail: Literal["auto", "high", "low"] = "auto"
|
|
"""
|
|
Detail parameter for LLM provider, if supported.
|
|
|
|
Currently only supported by OpenAI (see https://platform.openai.com/docs/guides/vision?lang=node#low-or-high-fidelity-image-understanding)
|
|
"""
|
|
mime_type: str | None = None
|
|
"""
|
|
MIME type of the image
|
|
"""
|
|
_cache: dict[Any, Any] = PrivateAttr(default_factory=dict)
|
|
|
|
|
|
class AudioContent(BaseModel):
|
|
type: Literal["audio_content"] = Field(default="audio_content")
|
|
frame: list[rtc.AudioFrame]
|
|
transcript: str | None = None
|
|
|
|
|
|
ChatRole: TypeAlias = Literal["developer", "system", "user", "assistant"]
|
|
|
|
|
|
# The metrics are stored in a dict, since some fields may not be relevant
|
|
# in certain context (e.g., text-only mode or when using a speech-to-speech model).
|
|
class MetricsMetadata(TypedDict, total=False):
|
|
model_name: str
|
|
model_provider: str
|
|
|
|
|
|
class MetricsReport(TypedDict, total=False):
|
|
started_speaking_at: float
|
|
stopped_speaking_at: float
|
|
|
|
transcription_delay: float
|
|
"""Time taken to obtain the transcript after the end of the user's speech
|
|
|
|
User `ChatMessage` only
|
|
"""
|
|
|
|
end_of_turn_delay: float
|
|
"""Amount of time between the end of speech and the decision to end the user's turn
|
|
|
|
User `ChatMessage` only
|
|
"""
|
|
|
|
on_user_turn_completed_delay: float
|
|
"""Time taken to invoke the developer's `Agent.on_user_turn_completed` callback.
|
|
|
|
User `ChatMessage` only
|
|
"""
|
|
|
|
llm_node_ttft: float
|
|
"""Time taken for the `llm_node` to return the first token
|
|
|
|
Assistant `ChatMessage` only
|
|
"""
|
|
|
|
tts_node_ttfb: float
|
|
"""Time taken for the `tts_node` to return the first chunk of audio (after the first text token has been sent)
|
|
|
|
Assistant `ChatMessage` only
|
|
"""
|
|
|
|
playback_latency: float
|
|
"""Delay between forwarding the first audio frame and the `AudioOutput` reporting
|
|
playback started. Near-zero for the default room output (self-reported when the frame
|
|
is pushed to the track, so it doesn't account for network delivery to the client);
|
|
meaningful when a remote avatar worker is in the chain and reports playback via
|
|
the `lk.playback_started` RPC.
|
|
|
|
Assistant `ChatMessage` only
|
|
"""
|
|
|
|
e2e_latency: float
|
|
"""Time from when the user finished speaking to when the agent began responding
|
|
|
|
Assistant `ChatMessage` only
|
|
"""
|
|
|
|
llm_metadata: MetricsMetadata
|
|
tts_metadata: MetricsMetadata
|
|
stt_metadata: MetricsMetadata
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
id: str = Field(default_factory=lambda: utils.shortuuid("item_"))
|
|
type: Literal["message"] = "message"
|
|
role: ChatRole
|
|
content: list[ChatContent]
|
|
interrupted: bool = False
|
|
transcript_confidence: float | None = None
|
|
extra: dict[str, Any] = Field(default_factory=dict)
|
|
metrics: MetricsReport = Field(default_factory=lambda: MetricsReport())
|
|
created_at: float = Field(default_factory=time.time)
|
|
hash: bytes | None = Field(default=None, deprecated="hash is deprecated")
|
|
|
|
@property
|
|
def text_content(self) -> str | None:
|
|
"""
|
|
Returns a string of all text content in the message, with LiveKit's
|
|
expressive ``<expr/>`` tags removed from assistant messages.
|
|
|
|
Multiple text content items will be joined by a newline.
|
|
Use :attr:`raw_text_content` for the exact model-facing content.
|
|
"""
|
|
raw = self.raw_text_content
|
|
if raw is None or self.role != "assistant":
|
|
return raw
|
|
|
|
from ..tts._provider_format import strip_expr_markup
|
|
|
|
return strip_expr_markup(raw)
|
|
|
|
@property
|
|
def raw_text_content(self) -> str | None:
|
|
"""
|
|
Returns a string of all text content in the message, exactly as generated
|
|
(assistant messages may contain expressive ``<expr/>`` tags).
|
|
|
|
Multiple text content items will be joined by a newline.
|
|
"""
|
|
text_parts = [c for c in self.content if isinstance(c, str)]
|
|
if not text_parts:
|
|
return None
|
|
return "\n".join(text_parts)
|
|
|
|
|
|
ChatContent: TypeAlias = ImageContent | AudioContent | str
|
|
|
|
|
|
class FunctionCall(BaseModel):
|
|
id: str = Field(default_factory=lambda: utils.shortuuid("item_"))
|
|
type: Literal["function_call"] = "function_call"
|
|
call_id: str
|
|
arguments: str
|
|
name: str
|
|
created_at: float = Field(default_factory=time.time)
|
|
extra: dict[str, Any] = Field(default_factory=dict)
|
|
"""Extra data for this function call. Can include provider-specific data
|
|
(e.g., extra["google"] for thought signatures)."""
|
|
group_id: str | None = None
|
|
"""Optional group ID for parallel function calls. When multiple function calls
|
|
should be grouped together (e.g., parallel tool calls from a single API response),
|
|
set this to a shared value. If not set, falls back to using id for grouping."""
|
|
|
|
|
|
class FunctionCallOutput(BaseModel):
|
|
id: str = Field(default_factory=lambda: utils.shortuuid("item_"))
|
|
type: Literal["function_call_output"] = Field(default="function_call_output")
|
|
name: str = Field(default="")
|
|
call_id: str
|
|
output: str
|
|
is_error: bool
|
|
created_at: float = Field(default_factory=time.time)
|
|
|
|
|
|
class AgentHandoff(BaseModel):
|
|
id: str = Field(default_factory=lambda: utils.shortuuid("item_"))
|
|
type: Literal["agent_handoff"] = Field(default="agent_handoff")
|
|
old_agent_id: str | None = None
|
|
new_agent_id: str
|
|
created_at: float = Field(default_factory=time.time)
|
|
|
|
|
|
class AgentConfigUpdate(BaseModel):
|
|
id: str = Field(default_factory=lambda: utils.shortuuid("item_"))
|
|
type: Literal["agent_config_update"] = Field(default="agent_config_update")
|
|
|
|
instructions: str | None = None
|
|
tools_added: list[str] | None = None
|
|
tools_removed: list[str] | None = None
|
|
|
|
created_at: float = Field(default_factory=time.time)
|
|
|
|
_tools: list[Tool] = PrivateAttr(default_factory=list)
|
|
"""Full tool definitions (in-memory only, not serialized)."""
|
|
|
|
|
|
ChatItem = Annotated[
|
|
ChatMessage | FunctionCall | FunctionCallOutput | AgentHandoff | AgentConfigUpdate,
|
|
Field(discriminator="type"),
|
|
]
|
|
|
|
|
|
class ChatContext:
|
|
def __init__(self, items: NotGivenOr[list[ChatItem]] = NOT_GIVEN):
|
|
self._items: list[ChatItem] = items if is_given(items) else []
|
|
|
|
@classmethod
|
|
def empty(cls) -> ChatContext:
|
|
return cls([])
|
|
|
|
@property
|
|
def items(self) -> list[ChatItem]:
|
|
return self._items
|
|
|
|
@items.setter
|
|
def items(self, items: list[ChatItem]) -> None:
|
|
self._items = items
|
|
|
|
def messages(self) -> list[ChatMessage]:
|
|
"""Return only chat messages, ignoring function calls, outputs, and other events."""
|
|
return [item for item in self._items if isinstance(item, ChatMessage)]
|
|
|
|
def add_message(
|
|
self,
|
|
*,
|
|
role: ChatRole,
|
|
content: list[ChatContent] | str,
|
|
id: NotGivenOr[str] = NOT_GIVEN,
|
|
interrupted: NotGivenOr[bool] = NOT_GIVEN,
|
|
created_at: NotGivenOr[float] = NOT_GIVEN,
|
|
metrics: NotGivenOr[MetricsReport] = NOT_GIVEN,
|
|
extra: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
|
|
) -> ChatMessage:
|
|
kwargs: dict[str, Any] = {}
|
|
if is_given(id):
|
|
kwargs["id"] = id
|
|
if is_given(interrupted):
|
|
kwargs["interrupted"] = interrupted
|
|
if is_given(created_at):
|
|
kwargs["created_at"] = created_at
|
|
if is_given(metrics):
|
|
kwargs["metrics"] = metrics
|
|
if is_given(extra):
|
|
kwargs["extra"] = extra
|
|
|
|
if isinstance(content, Instructions):
|
|
message = ChatMessage(role=role, content=[str(content)], **kwargs)
|
|
elif isinstance(content, str):
|
|
message = ChatMessage(role=role, content=[content], **kwargs)
|
|
else:
|
|
message = ChatMessage(role=role, content=content, **kwargs)
|
|
|
|
if is_given(created_at):
|
|
idx = self.find_insertion_index(created_at=created_at)
|
|
self._items.insert(idx, message)
|
|
else:
|
|
self._items.append(message)
|
|
return message
|
|
|
|
def insert(self, item: ChatItem | Sequence[ChatItem]) -> None:
|
|
"""Insert an item or list of items into the chat context by creation time."""
|
|
items = list(item) if isinstance(item, Sequence) else [item]
|
|
|
|
for _item in items:
|
|
idx = self.find_insertion_index(created_at=_item.created_at)
|
|
self._items.insert(idx, _item)
|
|
|
|
def remove(self, item: ChatItem | str) -> None:
|
|
"""Remove the first item from the chat context by ChatItem or item ID.
|
|
|
|
Raises ValueError if the item/ID is not found.
|
|
"""
|
|
idx = self.index_by_id(item.id if not isinstance(item, str) else item)
|
|
if idx is None:
|
|
raise ValueError(f"Item not found: {item!r}")
|
|
self._items.pop(idx)
|
|
|
|
def get_by_id(self, item_id: str) -> ChatItem | None:
|
|
return next((item for item in self.items if item.id == item_id), None)
|
|
|
|
def index_by_id(self, item_id: str) -> int | None:
|
|
return next((i for i, item in enumerate(self.items) if item.id == item_id), None)
|
|
|
|
def copy(
|
|
self,
|
|
*,
|
|
exclude_function_call: bool = False,
|
|
exclude_instructions: bool = False,
|
|
exclude_empty_message: bool = False,
|
|
exclude_handoff: bool = False,
|
|
exclude_config_update: bool = False,
|
|
tools: NotGivenOr[Sequence[Tool | Toolset | str]] = NOT_GIVEN,
|
|
) -> ChatContext:
|
|
items = []
|
|
|
|
from .tool_context import FunctionTool, RawFunctionTool, Toolset
|
|
|
|
def get_tool_names(
|
|
tools: Sequence[Tool | Toolset | str],
|
|
) -> Generator[str, None, None]:
|
|
for tool in tools:
|
|
if isinstance(tool, str):
|
|
yield tool
|
|
elif isinstance(tool, (FunctionTool, RawFunctionTool)):
|
|
yield tool.info.name
|
|
elif isinstance(tool, Toolset):
|
|
yield from get_tool_names(tool.tools)
|
|
else:
|
|
# TODO(theomonnom): other tools
|
|
continue
|
|
|
|
valid_tools = set(get_tool_names(tools)) if tools else set()
|
|
for item in self.items:
|
|
if exclude_function_call and item.type in [
|
|
"function_call",
|
|
"function_call_output",
|
|
]:
|
|
continue
|
|
|
|
if (
|
|
exclude_instructions
|
|
and item.type == "message"
|
|
and item.role in ["system", "developer"]
|
|
):
|
|
continue
|
|
|
|
if exclude_empty_message and item.type == "message" and not item.content:
|
|
continue
|
|
|
|
if exclude_handoff and item.type == "agent_handoff":
|
|
continue
|
|
|
|
if exclude_config_update and item.type == "agent_config_update":
|
|
continue
|
|
|
|
if (
|
|
is_given(tools)
|
|
and (item.type == "function_call" or item.type == "function_call_output")
|
|
and item.name not in valid_tools
|
|
):
|
|
continue
|
|
|
|
items.append(item)
|
|
|
|
return ChatContext(items)
|
|
|
|
def truncate(self, *, max_items: int) -> ChatContext:
|
|
"""Truncate the chat context to the last N items in place.
|
|
|
|
Removes leading function calls to avoid partial function outputs.
|
|
Preserves the first instruction message (system/developer) by adding it back
|
|
to the beginning.
|
|
"""
|
|
|
|
if len(self._items) <= max_items:
|
|
return self
|
|
|
|
instructions = next(
|
|
(
|
|
item
|
|
for item in self._items
|
|
if item.type == "message" and item.role in ("system", "developer")
|
|
),
|
|
None,
|
|
)
|
|
|
|
new_items = self._items[-max_items:]
|
|
|
|
# chat_ctx shouldn't start with function_call or function_call_output
|
|
while new_items and new_items[0].type in [
|
|
"function_call",
|
|
"function_call_output",
|
|
]:
|
|
new_items.pop(0)
|
|
|
|
if instructions and not any(item.id == instructions.id for item in new_items):
|
|
new_items.insert(0, instructions)
|
|
|
|
self._items[:] = new_items
|
|
return self
|
|
|
|
def merge(
|
|
self,
|
|
other_chat_ctx: ChatContext,
|
|
*,
|
|
exclude_function_call: bool = False,
|
|
exclude_instructions: bool = False,
|
|
exclude_config_update: bool = False,
|
|
) -> ChatContext:
|
|
"""Add messages from `other_chat_ctx` into this one, avoiding duplicates, and keep items sorted by created_at."""
|
|
existing_ids = {item.id for item in self._items}
|
|
|
|
for item in other_chat_ctx.items:
|
|
if exclude_function_call and item.type in [
|
|
"function_call",
|
|
"function_call_output",
|
|
]:
|
|
continue
|
|
|
|
if (
|
|
exclude_instructions
|
|
and item.type == "message"
|
|
and item.role in ["system", "developer"]
|
|
):
|
|
continue
|
|
|
|
if exclude_config_update and item.type == "agent_config_update":
|
|
continue
|
|
|
|
if item.id not in existing_ids:
|
|
idx = self.find_insertion_index(created_at=item.created_at)
|
|
self._items.insert(idx, item)
|
|
existing_ids.add(item.id)
|
|
|
|
return self
|
|
|
|
def to_dict(
|
|
self,
|
|
*,
|
|
exclude_image: bool = True,
|
|
exclude_audio: bool = True,
|
|
exclude_timestamp: bool = True,
|
|
exclude_function_call: bool = False,
|
|
exclude_metrics: bool = False,
|
|
exclude_config_update: bool = False,
|
|
strip_markup: bool = False,
|
|
) -> dict[str, Any]:
|
|
items: list[ChatItem] = []
|
|
for item in self.items:
|
|
if exclude_function_call and item.type in [
|
|
"function_call",
|
|
"function_call_output",
|
|
]:
|
|
continue
|
|
|
|
if exclude_config_update and item.type == "agent_config_update":
|
|
continue
|
|
|
|
if item.type == "message":
|
|
item = item.model_copy()
|
|
if exclude_image:
|
|
item.content = [c for c in item.content if not isinstance(c, ImageContent)]
|
|
if exclude_audio:
|
|
item.content = [c for c in item.content if not isinstance(c, AudioContent)]
|
|
# only strip the <expr/> dialect, and only in assistant messages
|
|
if strip_markup and item.role == "assistant":
|
|
from ..tts._provider_format import strip_expr_markup
|
|
|
|
item.content = [
|
|
strip_expr_markup(c) if isinstance(c, str) else c for c in item.content
|
|
]
|
|
|
|
items.append(item)
|
|
|
|
exclude_fields: set[str] = set()
|
|
if exclude_timestamp:
|
|
exclude_fields.add("created_at")
|
|
if exclude_metrics:
|
|
exclude_fields.add("metrics")
|
|
|
|
return {
|
|
"items": [
|
|
item.model_dump(
|
|
mode="json",
|
|
exclude_none=True,
|
|
exclude_defaults=False,
|
|
exclude=exclude_fields,
|
|
)
|
|
for item in items
|
|
],
|
|
}
|
|
|
|
@overload
|
|
def to_provider_format(
|
|
self,
|
|
format: Literal["openai", "openai.responses"],
|
|
*,
|
|
inject_dummy_user_message: bool = True,
|
|
) -> tuple[list[dict], Literal[None]]: ...
|
|
|
|
@overload
|
|
def to_provider_format(
|
|
self,
|
|
format: Literal["google"],
|
|
*,
|
|
inject_dummy_user_message: bool = True,
|
|
thought_signatures: dict[str, bytes] | None = None,
|
|
) -> tuple[list[dict], _provider_format.google.GoogleFormatData]: ...
|
|
|
|
@overload
|
|
def to_provider_format(
|
|
self, format: Literal["aws"], *, inject_dummy_user_message: bool = True
|
|
) -> tuple[list[dict], _provider_format.aws.BedrockFormatData]: ...
|
|
|
|
@overload
|
|
def to_provider_format(
|
|
self, format: Literal["anthropic"], *, inject_dummy_user_message: bool = True
|
|
) -> tuple[list[dict], _provider_format.anthropic.AnthropicFormatData]: ...
|
|
|
|
@overload
|
|
def to_provider_format(
|
|
self, format: Literal["mistralai"]
|
|
) -> tuple[list[dict], _provider_format.mistralai.MistralFormatData]: ...
|
|
|
|
@overload
|
|
def to_provider_format(self, format: str, **kwargs: Any) -> tuple[list[dict], Any]: ...
|
|
|
|
def to_provider_format(
|
|
self,
|
|
format: Literal["openai", "openai.responses", "google", "aws", "anthropic", "mistralai"]
|
|
| str,
|
|
*,
|
|
inject_dummy_user_message: bool = True,
|
|
**kwargs: Any,
|
|
) -> tuple[list[dict], Any]:
|
|
"""Convert the chat context to a provider-specific format.
|
|
|
|
If ``inject_dummy_user_message`` is ``True``, a dummy user message will be added
|
|
to the beginning or end of the chat context depending on the provider.
|
|
|
|
This is necessary because some providers expect a user message to be present for
|
|
generating a response.
|
|
"""
|
|
kwargs["inject_dummy_user_message"] = inject_dummy_user_message
|
|
|
|
if format == "openai":
|
|
return _provider_format.openai.to_chat_ctx(self, **kwargs)
|
|
elif format == "openai.responses":
|
|
return _provider_format.openai.to_responses_chat_ctx(self, **kwargs)
|
|
elif format == "google":
|
|
return _provider_format.google.to_chat_ctx(self, **kwargs)
|
|
elif format == "aws":
|
|
return _provider_format.aws.to_chat_ctx(self, **kwargs)
|
|
elif format == "anthropic":
|
|
return _provider_format.anthropic.to_chat_ctx(self, **kwargs)
|
|
elif format == "mistralai":
|
|
return _provider_format.mistralai.to_conversations_ctx(self)
|
|
else:
|
|
raise ValueError(f"Unsupported provider format: {format}")
|
|
|
|
def find_insertion_index(self, *, created_at: float) -> int:
|
|
"""
|
|
Returns the index to insert an item by creation time.
|
|
|
|
Iterates in reverse, assuming items are sorted by `created_at`.
|
|
Finds the position after the last item with `created_at <=` the given timestamp.
|
|
"""
|
|
for i in reversed(range(len(self._items))):
|
|
if self._items[i].created_at <= created_at:
|
|
return i + 1
|
|
|
|
return 0
|
|
|
|
def _upsert_item(self, item: ChatItem, *, allow_type_mismatch: bool = False) -> None:
|
|
"""Update an item with the same ID if it exists, otherwise append it."""
|
|
idx = self.index_by_id(item.id)
|
|
if idx is not None:
|
|
if not allow_type_mismatch and item.type != self._items[idx].type:
|
|
raise ValueError(f"Item type mismatch: {item.type} != {self._items[idx].type}")
|
|
self._items[idx] = item
|
|
else:
|
|
self._items.append(item)
|
|
|
|
async def _summarize(
|
|
self,
|
|
llm_v: LLM,
|
|
*,
|
|
keep_last_turns: int = 2,
|
|
) -> ChatContext:
|
|
# Split self.items into head/tail. Walk backward, counting only
|
|
# user/assistant ChatMessages toward the keep_last_turns budget (each
|
|
# turn = one user + one assistant message, so budget = keep_last_turns * 2).
|
|
# Everything from the split point onward — including any interleaved
|
|
# FunctionCall/FunctionCallOutput items — is preserved as-is in the tail.
|
|
msg_budget = keep_last_turns * 2
|
|
split_idx = len(self.items)
|
|
|
|
if msg_budget > 0:
|
|
msg_count = 0
|
|
for i in range(len(self.items) - 1, -1, -1):
|
|
item = self.items[i]
|
|
if isinstance(item, ChatMessage) and item.role in ("user", "assistant"):
|
|
msg_count += 1
|
|
if msg_count >= msg_budget:
|
|
split_idx = i
|
|
break
|
|
else:
|
|
# Not enough messages to fill the budget — nothing to summarize
|
|
return self
|
|
|
|
if split_idx == 0:
|
|
return self
|
|
|
|
head_items, tail_items = self.items[:split_idx], self.items[split_idx:]
|
|
|
|
# Build summarization input from head_items only.
|
|
to_summarize: list[ChatMessage | FunctionCall | FunctionCallOutput] = []
|
|
for item in head_items:
|
|
if isinstance(item, ChatMessage):
|
|
if item.role not in ("user", "assistant"):
|
|
continue
|
|
if item.extra.get("is_summary") is True: # avoid making summary of summaries
|
|
continue
|
|
|
|
if (item.text_content or "").strip():
|
|
to_summarize.append(item)
|
|
elif isinstance(item, (FunctionCall, FunctionCallOutput)):
|
|
to_summarize.append(item)
|
|
|
|
if not to_summarize:
|
|
return self
|
|
|
|
# Render items to XML format and collect the contents.
|
|
contents: list[str] = []
|
|
for m in to_summarize:
|
|
if isinstance(m, (FunctionCall, FunctionCallOutput)):
|
|
contents.append(_function_call_item_to_message(m).raw_text_content or "")
|
|
else:
|
|
contents.append(to_xml(m.role, (m.text_content or "").strip()))
|
|
|
|
source_text = "\n".join(contents).strip()
|
|
|
|
if not source_text:
|
|
return self
|
|
|
|
chat_ctx = ChatContext()
|
|
chat_ctx.add_message(
|
|
role="system",
|
|
content=textwrap.dedent("""\
|
|
Compress older conversation history into a short, faithful summary.
|
|
|
|
The conversation is formatted as XML. Here is how to read it:
|
|
- <user>…</user> — something the user said.
|
|
- <assistant>…</assistant> — something the assistant said.
|
|
- <function_call name="…" call_id="…">…</function_call> — the assistant invoked an action.
|
|
- <function_call_output name="…" call_id="…">…</function_call_output> — the result of that \
|
|
action. May contain <error>…</error> if it failed.
|
|
|
|
Guidelines:
|
|
- Distill the *information learned* from function call outputs into the summary. \
|
|
Do not mention that a tool/function was called — just preserve the knowledge gained.
|
|
- Focus on: user goals, constraints, decisions, key facts, preferences, entities, \
|
|
and any pending or unresolved tasks.
|
|
- Omit greetings, filler, and chit-chat.
|
|
- Be concise."""),
|
|
)
|
|
chat_ctx.add_message(
|
|
role="user",
|
|
content=f"Conversation to summarize:\n\n{source_text}",
|
|
)
|
|
|
|
chunks: list[str] = []
|
|
async with llm_v.chat(chat_ctx=chat_ctx) as stream:
|
|
async for chunk in stream:
|
|
if chunk.delta and chunk.delta.content:
|
|
chunks.append(chunk.delta.content)
|
|
|
|
summary = "".join(chunks).strip()
|
|
if not summary:
|
|
return self
|
|
|
|
# Rebuild self._items. From head_items, keep only structural
|
|
# items (system messages, agent handoffs, config updates, prior
|
|
# summaries) — everything summarizable is replaced by the summary.
|
|
# Tail items are appended as-is.
|
|
preserved: list[ChatItem] = []
|
|
for it in head_items:
|
|
if isinstance(it, ChatMessage) and it.role in ("user", "assistant"):
|
|
continue
|
|
if isinstance(it, (FunctionCall, FunctionCallOutput)):
|
|
continue
|
|
preserved.append(it)
|
|
|
|
self._items = preserved
|
|
|
|
created_at_hint = (
|
|
(tail_items[0].created_at - 1e-6) if tail_items else (head_items[-1].created_at + 1e-6)
|
|
)
|
|
self.add_message(
|
|
role="assistant",
|
|
content=to_xml("chat_history_summary", summary),
|
|
created_at=created_at_hint,
|
|
extra={"is_summary": True},
|
|
)
|
|
|
|
self._items.extend(tail_items)
|
|
|
|
return self
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> ChatContext:
|
|
item_adapter = TypeAdapter(list[ChatItem])
|
|
items = item_adapter.validate_python(data["items"])
|
|
return cls(items)
|
|
|
|
def to_proto(self) -> agent_pb.ChatContext:
|
|
from ..voice.remote_session import _chat_item_to_proto
|
|
|
|
return agent_pb.ChatContext(items=[_chat_item_to_proto(item) for item in self.items])
|
|
|
|
@property
|
|
def readonly(self) -> bool:
|
|
return False
|
|
|
|
def is_equivalent(self, other: ChatContext) -> bool:
|
|
"""
|
|
Return True if `other` has the same sequence of items with matching
|
|
essential fields (IDs, types, and payload) as this context.
|
|
|
|
Comparison rules:
|
|
- Messages: compares the full `content` list, `role` and `interrupted`.
|
|
- Function calls: compares `name`, `call_id`, and `arguments`.
|
|
- Function call outputs: compares `name`, `call_id`, `output`, and `is_error`.
|
|
|
|
Does not consider timestamps or other metadata.
|
|
"""
|
|
if self is other:
|
|
return True
|
|
|
|
if len(self.items) != len(other.items):
|
|
return False
|
|
|
|
for a, b in zip(self.items, other.items, strict=False):
|
|
if a.id != b.id or a.type != b.type:
|
|
return False
|
|
|
|
if a.type == "message" and b.type == "message":
|
|
if a.role != b.role or a.interrupted != b.interrupted or a.content != b.content:
|
|
return False
|
|
|
|
elif a.type == "function_call" and b.type == "function_call":
|
|
if a.name != b.name or a.call_id != b.call_id or a.arguments != b.arguments:
|
|
return False
|
|
|
|
elif a.type == "function_call_output" and b.type == "function_call_output":
|
|
if (
|
|
a.name != b.name
|
|
or a.call_id != b.call_id
|
|
or a.output != b.output
|
|
or a.is_error != b.is_error
|
|
):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
class _ReadOnlyChatContext(ChatContext):
|
|
"""A read-only wrapper for ChatContext that prevents modifications."""
|
|
|
|
error_msg = (
|
|
"trying to modify a read-only chat context, "
|
|
"please use .copy() and agent.update_chat_ctx() to modify the chat context"
|
|
)
|
|
|
|
class _ImmutableList(list[ChatItem]):
|
|
def _raise_error(self, *args: Any, **kwargs: Any) -> None:
|
|
logger.error(_ReadOnlyChatContext.error_msg)
|
|
raise RuntimeError(_ReadOnlyChatContext.error_msg)
|
|
|
|
# override all mutating methods to raise errors
|
|
append = extend = pop = remove = clear = sort = reverse = _raise_error # type: ignore
|
|
__setitem__ = __delitem__ = __iadd__ = __imul__ = _raise_error # type: ignore
|
|
|
|
def copy(self) -> list[ChatItem]:
|
|
return list(self)
|
|
|
|
def __init__(self, items: list[ChatItem]):
|
|
self._items = self._ImmutableList(items)
|
|
|
|
@property
|
|
def readonly(self) -> bool:
|
|
return True
|
|
|
|
|
|
def _to_attrs_str(attrs: dict[str, Any] | None = None) -> str | None:
|
|
if attrs:
|
|
return " ".join([f'{k}="{v}"' for k, v in attrs.items()])
|
|
return None
|
|
|
|
|
|
def to_xml(
|
|
tag_name: str,
|
|
content: str | None = None,
|
|
attrs: dict[str, Any] | None = None,
|
|
) -> str:
|
|
attrs_str = _to_attrs_str(attrs)
|
|
|
|
if content:
|
|
return "\n".join(
|
|
[
|
|
f"<{tag_name} {attrs_str}>" if attrs_str else f"<{tag_name}>",
|
|
content,
|
|
f"</{tag_name}>",
|
|
]
|
|
)
|
|
else:
|
|
return f"<{tag_name} {attrs_str} />" if attrs_str else f"<{tag_name} />"
|
|
|
|
|
|
def _function_call_item_to_message(item: FunctionCall | FunctionCallOutput) -> ChatMessage:
|
|
if isinstance(item, FunctionCall):
|
|
return ChatMessage(
|
|
role="user",
|
|
content=[
|
|
to_xml(
|
|
"function_call",
|
|
item.arguments,
|
|
attrs={
|
|
"name": item.name,
|
|
"call_id": item.call_id,
|
|
},
|
|
)
|
|
],
|
|
created_at=item.created_at,
|
|
extra={"is_function_call": True},
|
|
)
|
|
elif isinstance(item, FunctionCallOutput):
|
|
return ChatMessage(
|
|
role="assistant",
|
|
content=[
|
|
to_xml(
|
|
"function_call_output",
|
|
item.output if not item.is_error else to_xml("error", item.output),
|
|
attrs={
|
|
"call_id": item.call_id,
|
|
"name": item.name,
|
|
},
|
|
)
|
|
],
|
|
created_at=item.created_at,
|
|
extra={"is_function_call_output": True},
|
|
)
|