Coverage for pydantic_ai_slim/pydantic_ai/messages.py: 94.27%
383 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-28 17:27 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-28 17:27 +0000
1from __future__ import annotations as _annotations
3import uuid
4from collections.abc import Sequence
5from dataclasses import dataclass, field, replace
6from datetime import datetime
7from mimetypes import guess_type
8from typing import Annotated, Any, Literal, Union, cast, overload
10import pydantic
11import pydantic_core
12from opentelemetry._events import Event
13from typing_extensions import TypeAlias
15from ._utils import generate_tool_call_id as _generate_tool_call_id, now_utc as _now_utc
16from .exceptions import UnexpectedModelBehavior
19@dataclass
20class SystemPromptPart:
21 """A system prompt, generally written by the application developer.
23 This gives the model context and guidance on how to respond.
24 """
26 content: str
27 """The content of the prompt."""
29 timestamp: datetime = field(default_factory=_now_utc)
30 """The timestamp of the prompt."""
32 dynamic_ref: str | None = None
33 """The ref of the dynamic system prompt function that generated this part.
35 Only set if system prompt is dynamic, see [`system_prompt`][pydantic_ai.Agent.system_prompt] for more information.
36 """
38 part_kind: Literal['system-prompt'] = 'system-prompt'
39 """Part type identifier, this is available on all parts as a discriminator."""
41 def otel_event(self) -> Event:
42 return Event('gen_ai.system.message', body={'content': self.content, 'role': 'system'})
45@dataclass
46class AudioUrl:
47 """A URL to an audio file."""
49 url: str
50 """The URL of the audio file."""
52 kind: Literal['audio-url'] = 'audio-url'
53 """Type identifier, this is available on all parts as a discriminator."""
55 @property
56 def media_type(self) -> AudioMediaType:
57 """Return the media type of the audio file, based on the url."""
58 if self.url.endswith('.mp3'):
59 return 'audio/mpeg'
60 elif self.url.endswith('.wav'):
61 return 'audio/wav'
62 else:
63 raise ValueError(f'Unknown audio file extension: {self.url}')
66@dataclass
67class ImageUrl:
68 """A URL to an image."""
70 url: str
71 """The URL of the image."""
73 kind: Literal['image-url'] = 'image-url'
74 """Type identifier, this is available on all parts as a discriminator."""
76 @property
77 def media_type(self) -> ImageMediaType:
78 """Return the media type of the image, based on the url."""
79 if self.url.endswith(('.jpg', '.jpeg')):
80 return 'image/jpeg'
81 elif self.url.endswith('.png'): 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true
82 return 'image/png'
83 elif self.url.endswith('.gif'): 83 ↛ 84line 83 didn't jump to line 84 because the condition on line 83 was never true
84 return 'image/gif'
85 elif self.url.endswith('.webp'): 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 return 'image/webp'
87 else:
88 raise ValueError(f'Unknown image file extension: {self.url}')
90 @property
91 def format(self) -> ImageFormat:
92 """The file format of the image.
94 The choice of supported formats were based on the Bedrock Converse API. Other APIs don't require to use a format.
95 """
96 return _image_format(self.media_type)
99@dataclass
100class DocumentUrl:
101 """The URL of the document."""
103 url: str
104 """The URL of the document."""
106 kind: Literal['document-url'] = 'document-url'
107 """Type identifier, this is available on all parts as a discriminator."""
109 @property
110 def media_type(self) -> str:
111 """Return the media type of the document, based on the url."""
112 type_, _ = guess_type(self.url)
113 if type_ is None:
114 raise RuntimeError(f'Unknown document file extension: {self.url}')
115 return type_
117 @property
118 def format(self) -> DocumentFormat:
119 """The file format of the document.
121 The choice of supported formats were based on the Bedrock Converse API. Other APIs don't require to use a format.
122 """
123 return _document_format(self.media_type)
126AudioMediaType: TypeAlias = Literal['audio/wav', 'audio/mpeg']
127ImageMediaType: TypeAlias = Literal['image/jpeg', 'image/png', 'image/gif', 'image/webp']
128DocumentMediaType: TypeAlias = Literal[
129 'application/pdf',
130 'text/plain',
131 'text/csv',
132 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
133 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
134 'text/html',
135 'text/markdown',
136 'application/vnd.ms-excel',
137]
138AudioFormat: TypeAlias = Literal['wav', 'mp3']
139ImageFormat: TypeAlias = Literal['jpeg', 'png', 'gif', 'webp']
140DocumentFormat: TypeAlias = Literal['csv', 'doc', 'docx', 'html', 'md', 'pdf', 'txt', 'xls', 'xlsx']
143@dataclass
144class BinaryContent:
145 """Binary content, e.g. an audio or image file."""
147 data: bytes
148 """The binary data."""
150 media_type: AudioMediaType | ImageMediaType | DocumentMediaType | str
151 """The media type of the binary data."""
153 kind: Literal['binary'] = 'binary'
154 """Type identifier, this is available on all parts as a discriminator."""
156 @property
157 def is_audio(self) -> bool:
158 """Return `True` if the media type is an audio type."""
159 return self.media_type.startswith('audio/')
161 @property
162 def is_image(self) -> bool:
163 """Return `True` if the media type is an image type."""
164 return self.media_type.startswith('image/')
166 @property
167 def is_document(self) -> bool:
168 """Return `True` if the media type is a document type."""
169 return self.media_type in {
170 'application/pdf',
171 'text/plain',
172 'text/csv',
173 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
174 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
175 'text/html',
176 'text/markdown',
177 'application/vnd.ms-excel',
178 }
180 @property
181 def format(self) -> str:
182 """The file format of the binary content."""
183 if self.is_audio:
184 if self.media_type == 'audio/mpeg':
185 return 'mp3'
186 elif self.media_type == 'audio/wav': 186 ↛ 192line 186 didn't jump to line 192 because the condition on line 186 was always true
187 return 'wav'
188 elif self.is_image:
189 return _image_format(self.media_type)
190 elif self.is_document: 190 ↛ 192line 190 didn't jump to line 192 because the condition on line 190 was always true
191 return _document_format(self.media_type)
192 raise ValueError(f'Unknown media type: {self.media_type}')
195UserContent: TypeAlias = 'str | ImageUrl | AudioUrl | DocumentUrl | BinaryContent'
198def _document_format(media_type: str) -> DocumentFormat:
199 if media_type == 'application/pdf':
200 return 'pdf'
201 elif media_type == 'text/plain':
202 return 'txt'
203 elif media_type == 'text/csv':
204 return 'csv'
205 elif media_type == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
206 return 'docx'
207 elif media_type == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
208 return 'xlsx'
209 elif media_type == 'text/html':
210 return 'html'
211 elif media_type == 'text/markdown':
212 return 'md'
213 elif media_type == 'application/vnd.ms-excel': 213 ↛ 216line 213 didn't jump to line 216 because the condition on line 213 was always true
214 return 'xls'
215 else:
216 raise ValueError(f'Unknown document media type: {media_type}')
219def _image_format(media_type: str) -> ImageFormat:
220 if media_type == 'image/jpeg':
221 return 'jpeg'
222 elif media_type == 'image/png':
223 return 'png'
224 elif media_type == 'image/gif':
225 return 'gif'
226 elif media_type == 'image/webp': 226 ↛ 229line 226 didn't jump to line 229 because the condition on line 226 was always true
227 return 'webp'
228 else:
229 raise ValueError(f'Unknown image media type: {media_type}')
232@dataclass
233class UserPromptPart:
234 """A user prompt, generally written by the end user.
236 Content comes from the `user_prompt` parameter of [`Agent.run`][pydantic_ai.Agent.run],
237 [`Agent.run_sync`][pydantic_ai.Agent.run_sync], and [`Agent.run_stream`][pydantic_ai.Agent.run_stream].
238 """
240 content: str | Sequence[UserContent]
241 """The content of the prompt."""
243 timestamp: datetime = field(default_factory=_now_utc)
244 """The timestamp of the prompt."""
246 part_kind: Literal['user-prompt'] = 'user-prompt'
247 """Part type identifier, this is available on all parts as a discriminator."""
249 def otel_event(self) -> Event:
250 if isinstance(self.content, str):
251 content = self.content
252 else:
253 # TODO figure out what to record for images and audio
254 content = [part if isinstance(part, str) else {'kind': part.kind} for part in self.content]
255 return Event('gen_ai.user.message', body={'content': content, 'role': 'user'})
258tool_return_ta: pydantic.TypeAdapter[Any] = pydantic.TypeAdapter(Any, config=pydantic.ConfigDict(defer_build=True))
261@dataclass
262class ToolReturnPart:
263 """A tool return message, this encodes the result of running a tool."""
265 tool_name: str
266 """The name of the "tool" was called."""
268 content: Any
269 """The return value."""
271 tool_call_id: str
272 """The tool call identifier, this is used by some models including OpenAI."""
274 timestamp: datetime = field(default_factory=_now_utc)
275 """The timestamp, when the tool returned."""
277 part_kind: Literal['tool-return'] = 'tool-return'
278 """Part type identifier, this is available on all parts as a discriminator."""
280 def model_response_str(self) -> str:
281 """Return a string representation of the content for the model."""
282 if isinstance(self.content, str):
283 return self.content
284 else:
285 return tool_return_ta.dump_json(self.content).decode()
287 def model_response_object(self) -> dict[str, Any]:
288 """Return a dictionary representation of the content, wrapping non-dict types appropriately."""
289 # gemini supports JSON dict return values, but no other JSON types, hence we wrap anything else in a dict
290 if isinstance(self.content, dict): 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true
291 return tool_return_ta.dump_python(self.content, mode='json') # pyright: ignore[reportUnknownMemberType]
292 else:
293 return {'return_value': tool_return_ta.dump_python(self.content, mode='json')}
295 def otel_event(self) -> Event:
296 return Event(
297 'gen_ai.tool.message',
298 body={'content': self.content, 'role': 'tool', 'id': self.tool_call_id, 'name': self.tool_name},
299 )
302error_details_ta = pydantic.TypeAdapter(list[pydantic_core.ErrorDetails], config=pydantic.ConfigDict(defer_build=True))
305@dataclass
306class RetryPromptPart:
307 """A message back to a model asking it to try again.
309 This can be sent for a number of reasons:
311 * Pydantic validation of tool arguments failed, here content is derived from a Pydantic
312 [`ValidationError`][pydantic_core.ValidationError]
313 * a tool raised a [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] exception
314 * no tool was found for the tool name
315 * the model returned plain text when a structured response was expected
316 * Pydantic validation of a structured response failed, here content is derived from a Pydantic
317 [`ValidationError`][pydantic_core.ValidationError]
318 * a result validator raised a [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] exception
319 """
321 content: list[pydantic_core.ErrorDetails] | str
322 """Details of why and how the model should retry.
324 If the retry was triggered by a [`ValidationError`][pydantic_core.ValidationError], this will be a list of
325 error details.
326 """
328 tool_name: str | None = None
329 """The name of the tool that was called, if any."""
331 tool_call_id: str = field(default_factory=_generate_tool_call_id)
332 """The tool call identifier, this is used by some models including OpenAI.
334 In case the tool call id is not provided by the model, PydanticAI will generate a random one.
335 """
337 timestamp: datetime = field(default_factory=_now_utc)
338 """The timestamp, when the retry was triggered."""
340 part_kind: Literal['retry-prompt'] = 'retry-prompt'
341 """Part type identifier, this is available on all parts as a discriminator."""
343 def model_response(self) -> str:
344 """Return a string message describing why the retry is requested."""
345 if isinstance(self.content, str):
346 description = self.content
347 else:
348 json_errors = error_details_ta.dump_json(self.content, exclude={'__all__': {'ctx'}}, indent=2)
349 description = f'{len(self.content)} validation errors: {json_errors.decode()}'
350 return f'{description}\n\nFix the errors and try again.'
352 def otel_event(self) -> Event:
353 if self.tool_name is None:
354 return Event('gen_ai.user.message', body={'content': self.model_response(), 'role': 'user'})
355 else:
356 return Event(
357 'gen_ai.tool.message',
358 body={
359 'content': self.model_response(),
360 'role': 'tool',
361 'id': self.tool_call_id,
362 'name': self.tool_name,
363 },
364 )
367ModelRequestPart = Annotated[
368 Union[SystemPromptPart, UserPromptPart, ToolReturnPart, RetryPromptPart], pydantic.Discriminator('part_kind')
369]
370"""A message part sent by PydanticAI to a model."""
373@dataclass
374class ModelRequest:
375 """A request generated by PydanticAI and sent to a model, e.g. a message from the PydanticAI app to the model."""
377 parts: list[ModelRequestPart]
378 """The parts of the user message."""
380 kind: Literal['request'] = 'request'
381 """Message type identifier, this is available on all parts as a discriminator."""
384@dataclass
385class TextPart:
386 """A plain text response from a model."""
388 content: str
389 """The text content of the response."""
391 part_kind: Literal['text'] = 'text'
392 """Part type identifier, this is available on all parts as a discriminator."""
394 def has_content(self) -> bool:
395 """Return `True` if the text content is non-empty."""
396 return bool(self.content)
399@dataclass
400class ToolCallPart:
401 """A tool call from a model."""
403 tool_name: str
404 """The name of the tool to call."""
406 args: str | dict[str, Any]
407 """The arguments to pass to the tool.
409 This is stored either as a JSON string or a Python dictionary depending on how data was received.
410 """
412 tool_call_id: str = field(default_factory=_generate_tool_call_id)
413 """The tool call identifier, this is used by some models including OpenAI.
415 In case the tool call id is not provided by the model, PydanticAI will generate a random one.
416 """
418 part_kind: Literal['tool-call'] = 'tool-call'
419 """Part type identifier, this is available on all parts as a discriminator."""
421 def args_as_dict(self) -> dict[str, Any]:
422 """Return the arguments as a Python dictionary.
424 This is just for convenience with models that require dicts as input.
425 """
426 if isinstance(self.args, dict):
427 return self.args
428 args = pydantic_core.from_json(self.args)
429 assert isinstance(args, dict), 'args should be a dict'
430 return cast(dict[str, Any], args)
432 def args_as_json_str(self) -> str:
433 """Return the arguments as a JSON string.
435 This is just for convenience with models that require JSON strings as input.
436 """
437 if isinstance(self.args, str):
438 return self.args
439 return pydantic_core.to_json(self.args).decode()
441 def has_content(self) -> bool:
442 """Return `True` if the arguments contain any data."""
443 if isinstance(self.args, dict):
444 # TODO: This should probably return True if you have the value False, or 0, etc.
445 # It makes sense to me to ignore empty strings, but not sure about empty lists or dicts
446 return any(self.args.values())
447 else:
448 return bool(self.args)
451ModelResponsePart = Annotated[Union[TextPart, ToolCallPart], pydantic.Discriminator('part_kind')]
452"""A message part returned by a model."""
455@dataclass
456class ModelResponse:
457 """A response from a model, e.g. a message from the model to the PydanticAI app."""
459 parts: list[ModelResponsePart]
460 """The parts of the model message."""
462 model_name: str | None = None
463 """The name of the model that generated the response."""
465 timestamp: datetime = field(default_factory=_now_utc)
466 """The timestamp of the response.
468 If the model provides a timestamp in the response (as OpenAI does) that will be used.
469 """
471 kind: Literal['response'] = 'response'
472 """Message type identifier, this is available on all parts as a discriminator."""
474 def otel_events(self) -> list[Event]:
475 """Return OpenTelemetry events for the response."""
476 result: list[Event] = []
478 def new_event_body():
479 new_body: dict[str, Any] = {'role': 'assistant'}
480 ev = Event('gen_ai.assistant.message', body=new_body)
481 result.append(ev)
482 return new_body
484 body = new_event_body()
485 for part in self.parts:
486 if isinstance(part, ToolCallPart):
487 body.setdefault('tool_calls', []).append(
488 {
489 'id': part.tool_call_id,
490 'type': 'function', # TODO https://github.com/pydantic/pydantic-ai/issues/888
491 'function': {
492 'name': part.tool_name,
493 'arguments': part.args,
494 },
495 }
496 )
497 elif isinstance(part, TextPart):
498 if body.get('content'):
499 body = new_event_body()
500 body['content'] = part.content
502 return result
505ModelMessage = Annotated[Union[ModelRequest, ModelResponse], pydantic.Discriminator('kind')]
506"""Any message sent to or returned by a model."""
508ModelMessagesTypeAdapter = pydantic.TypeAdapter(
509 list[ModelMessage], config=pydantic.ConfigDict(defer_build=True, ser_json_bytes='base64')
510)
511"""Pydantic [`TypeAdapter`][pydantic.type_adapter.TypeAdapter] for (de)serializing messages."""
514@dataclass
515class TextPartDelta:
516 """A partial update (delta) for a `TextPart` to append new text content."""
518 content_delta: str
519 """The incremental text content to add to the existing `TextPart` content."""
521 part_delta_kind: Literal['text'] = 'text'
522 """Part delta type identifier, used as a discriminator."""
524 def apply(self, part: ModelResponsePart) -> TextPart:
525 """Apply this text delta to an existing `TextPart`.
527 Args:
528 part: The existing model response part, which must be a `TextPart`.
530 Returns:
531 A new `TextPart` with updated text content.
533 Raises:
534 ValueError: If `part` is not a `TextPart`.
535 """
536 if not isinstance(part, TextPart): 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 raise ValueError('Cannot apply TextPartDeltas to non-TextParts')
538 return replace(part, content=part.content + self.content_delta)
541@dataclass
542class ToolCallPartDelta:
543 """A partial update (delta) for a `ToolCallPart` to modify tool name, arguments, or tool call ID."""
545 tool_name_delta: str | None = None
546 """Incremental text to add to the existing tool name, if any."""
548 args_delta: str | dict[str, Any] | None = None
549 """Incremental data to add to the tool arguments.
551 If this is a string, it will be appended to existing JSON arguments.
552 If this is a dict, it will be merged with existing dict arguments.
553 """
555 tool_call_id: str | None = None
556 """Optional tool call identifier, this is used by some models including OpenAI.
558 Note this is never treated as a delta — it can replace None, but otherwise if a
559 non-matching value is provided an error will be raised."""
561 part_delta_kind: Literal['tool_call'] = 'tool_call'
562 """Part delta type identifier, used as a discriminator."""
564 def as_part(self) -> ToolCallPart | None:
565 """Convert this delta to a fully formed `ToolCallPart` if possible, otherwise return `None`.
567 Returns:
568 A `ToolCallPart` if both `tool_name_delta` and `args_delta` are set, otherwise `None`.
569 """
570 if self.tool_name_delta is None or self.args_delta is None:
571 return None
573 return ToolCallPart(self.tool_name_delta, self.args_delta, self.tool_call_id or _generate_tool_call_id())
575 @overload
576 def apply(self, part: ModelResponsePart) -> ToolCallPart: ...
578 @overload
579 def apply(self, part: ModelResponsePart | ToolCallPartDelta) -> ToolCallPart | ToolCallPartDelta: ...
581 def apply(self, part: ModelResponsePart | ToolCallPartDelta) -> ToolCallPart | ToolCallPartDelta:
582 """Apply this delta to a part or delta, returning a new part or delta with the changes applied.
584 Args:
585 part: The existing model response part or delta to update.
587 Returns:
588 Either a new `ToolCallPart` or an updated `ToolCallPartDelta`.
590 Raises:
591 ValueError: If `part` is neither a `ToolCallPart` nor a `ToolCallPartDelta`.
592 UnexpectedModelBehavior: If applying JSON deltas to dict arguments or vice versa.
593 """
594 if isinstance(part, ToolCallPart):
595 return self._apply_to_part(part)
597 if isinstance(part, ToolCallPartDelta): 597 ↛ 600line 597 didn't jump to line 600 because the condition on line 597 was always true
598 return self._apply_to_delta(part)
600 raise ValueError(f'Can only apply ToolCallPartDeltas to ToolCallParts or ToolCallPartDeltas, not {part}')
602 def _apply_to_delta(self, delta: ToolCallPartDelta) -> ToolCallPart | ToolCallPartDelta:
603 """Internal helper to apply this delta to another delta."""
604 if self.tool_name_delta:
605 # Append incremental text to the existing tool_name_delta
606 updated_tool_name_delta = (delta.tool_name_delta or '') + self.tool_name_delta
607 delta = replace(delta, tool_name_delta=updated_tool_name_delta)
609 if isinstance(self.args_delta, str):
610 if isinstance(delta.args_delta, dict):
611 raise UnexpectedModelBehavior(
612 f'Cannot apply JSON deltas to non-JSON tool arguments ({delta=}, {self=})'
613 )
614 updated_args_delta = (delta.args_delta or '') + self.args_delta
615 delta = replace(delta, args_delta=updated_args_delta)
616 elif isinstance(self.args_delta, dict):
617 if isinstance(delta.args_delta, str):
618 raise UnexpectedModelBehavior(
619 f'Cannot apply dict deltas to non-dict tool arguments ({delta=}, {self=})'
620 )
621 updated_args_delta = {**(delta.args_delta or {}), **self.args_delta}
622 delta = replace(delta, args_delta=updated_args_delta)
624 if self.tool_call_id:
625 delta = replace(delta, tool_call_id=self.tool_call_id)
627 # If we now have enough data to create a full ToolCallPart, do so
628 if delta.tool_name_delta is not None and delta.args_delta is not None:
629 return ToolCallPart(delta.tool_name_delta, delta.args_delta, delta.tool_call_id or _generate_tool_call_id())
631 return delta
633 def _apply_to_part(self, part: ToolCallPart) -> ToolCallPart:
634 """Internal helper to apply this delta directly to a `ToolCallPart`."""
635 if self.tool_name_delta:
636 # Append incremental text to the existing tool_name
637 tool_name = part.tool_name + self.tool_name_delta
638 part = replace(part, tool_name=tool_name)
640 if isinstance(self.args_delta, str):
641 if not isinstance(part.args, str):
642 raise UnexpectedModelBehavior(f'Cannot apply JSON deltas to non-JSON tool arguments ({part=}, {self=})')
643 updated_json = part.args + self.args_delta
644 part = replace(part, args=updated_json)
645 elif isinstance(self.args_delta, dict):
646 if not isinstance(part.args, dict):
647 raise UnexpectedModelBehavior(f'Cannot apply dict deltas to non-dict tool arguments ({part=}, {self=})')
648 updated_dict = {**(part.args or {}), **self.args_delta}
649 part = replace(part, args=updated_dict)
651 if self.tool_call_id:
652 part = replace(part, tool_call_id=self.tool_call_id)
653 return part
656ModelResponsePartDelta = Annotated[Union[TextPartDelta, ToolCallPartDelta], pydantic.Discriminator('part_delta_kind')]
657"""A partial update (delta) for any model response part."""
660@dataclass
661class PartStartEvent:
662 """An event indicating that a new part has started.
664 If multiple `PartStartEvent`s are received with the same index,
665 the new one should fully replace the old one.
666 """
668 index: int
669 """The index of the part within the overall response parts list."""
671 part: ModelResponsePart
672 """The newly started `ModelResponsePart`."""
674 event_kind: Literal['part_start'] = 'part_start'
675 """Event type identifier, used as a discriminator."""
678@dataclass
679class PartDeltaEvent:
680 """An event indicating a delta update for an existing part."""
682 index: int
683 """The index of the part within the overall response parts list."""
685 delta: ModelResponsePartDelta
686 """The delta to apply to the specified part."""
688 event_kind: Literal['part_delta'] = 'part_delta'
689 """Event type identifier, used as a discriminator."""
692@dataclass
693class FinalResultEvent:
694 """An event indicating the response to the current model request matches the result schema."""
696 tool_name: str | None
697 """The name of the result tool that was called. `None` if the result is from text content and not from a tool."""
698 tool_call_id: str | None
699 """The tool call ID, if any, that this result is associated with."""
700 event_kind: Literal['final_result'] = 'final_result'
701 """Event type identifier, used as a discriminator."""
704ModelResponseStreamEvent = Annotated[Union[PartStartEvent, PartDeltaEvent], pydantic.Discriminator('event_kind')]
705"""An event in the model response stream, either starting a new part or applying a delta to an existing one."""
707AgentStreamEvent = Annotated[
708 Union[PartStartEvent, PartDeltaEvent, FinalResultEvent], pydantic.Discriminator('event_kind')
709]
710"""An event in the agent stream."""
713@dataclass
714class FunctionToolCallEvent:
715 """An event indicating the start to a call to a function tool."""
717 part: ToolCallPart
718 """The (function) tool call to make."""
719 call_id: str = field(init=False)
720 """An ID used for matching details about the call to its result. If present, defaults to the part's tool_call_id."""
721 event_kind: Literal['function_tool_call'] = 'function_tool_call'
722 """Event type identifier, used as a discriminator."""
724 def __post_init__(self):
725 self.call_id = self.part.tool_call_id or str(uuid.uuid4())
728@dataclass
729class FunctionToolResultEvent:
730 """An event indicating the result of a function tool call."""
732 result: ToolReturnPart | RetryPromptPart
733 """The result of the call to the function tool."""
734 tool_call_id: str
735 """An ID used to match the result to its original call."""
736 event_kind: Literal['function_tool_result'] = 'function_tool_result'
737 """Event type identifier, used as a discriminator."""
740HandleResponseEvent = Annotated[Union[FunctionToolCallEvent, FunctionToolResultEvent], pydantic.Discriminator('kind')]