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

1from __future__ import annotations as _annotations 

2 

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 

9 

10import pydantic 

11import pydantic_core 

12from opentelemetry._events import Event 

13from typing_extensions import TypeAlias 

14 

15from ._utils import generate_tool_call_id as _generate_tool_call_id, now_utc as _now_utc 

16from .exceptions import UnexpectedModelBehavior 

17 

18 

19@dataclass 

20class SystemPromptPart: 

21 """A system prompt, generally written by the application developer. 

22 

23 This gives the model context and guidance on how to respond. 

24 """ 

25 

26 content: str 

27 """The content of the prompt.""" 

28 

29 timestamp: datetime = field(default_factory=_now_utc) 

30 """The timestamp of the prompt.""" 

31 

32 dynamic_ref: str | None = None 

33 """The ref of the dynamic system prompt function that generated this part. 

34 

35 Only set if system prompt is dynamic, see [`system_prompt`][pydantic_ai.Agent.system_prompt] for more information. 

36 """ 

37 

38 part_kind: Literal['system-prompt'] = 'system-prompt' 

39 """Part type identifier, this is available on all parts as a discriminator.""" 

40 

41 def otel_event(self) -> Event: 

42 return Event('gen_ai.system.message', body={'content': self.content, 'role': 'system'}) 

43 

44 

45@dataclass 

46class AudioUrl: 

47 """A URL to an audio file.""" 

48 

49 url: str 

50 """The URL of the audio file.""" 

51 

52 kind: Literal['audio-url'] = 'audio-url' 

53 """Type identifier, this is available on all parts as a discriminator.""" 

54 

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}') 

64 

65 

66@dataclass 

67class ImageUrl: 

68 """A URL to an image.""" 

69 

70 url: str 

71 """The URL of the image.""" 

72 

73 kind: Literal['image-url'] = 'image-url' 

74 """Type identifier, this is available on all parts as a discriminator.""" 

75 

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}') 

89 

90 @property 

91 def format(self) -> ImageFormat: 

92 """The file format of the image. 

93 

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) 

97 

98 

99@dataclass 

100class DocumentUrl: 

101 """The URL of the document.""" 

102 

103 url: str 

104 """The URL of the document.""" 

105 

106 kind: Literal['document-url'] = 'document-url' 

107 """Type identifier, this is available on all parts as a discriminator.""" 

108 

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_ 

116 

117 @property 

118 def format(self) -> DocumentFormat: 

119 """The file format of the document. 

120 

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) 

124 

125 

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'] 

141 

142 

143@dataclass 

144class BinaryContent: 

145 """Binary content, e.g. an audio or image file.""" 

146 

147 data: bytes 

148 """The binary data.""" 

149 

150 media_type: AudioMediaType | ImageMediaType | DocumentMediaType | str 

151 """The media type of the binary data.""" 

152 

153 kind: Literal['binary'] = 'binary' 

154 """Type identifier, this is available on all parts as a discriminator.""" 

155 

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/') 

160 

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/') 

165 

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 } 

179 

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}') 

193 

194 

195UserContent: TypeAlias = 'str | ImageUrl | AudioUrl | DocumentUrl | BinaryContent' 

196 

197 

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}') 

217 

218 

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}') 

230 

231 

232@dataclass 

233class UserPromptPart: 

234 """A user prompt, generally written by the end user. 

235 

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 """ 

239 

240 content: str | Sequence[UserContent] 

241 """The content of the prompt.""" 

242 

243 timestamp: datetime = field(default_factory=_now_utc) 

244 """The timestamp of the prompt.""" 

245 

246 part_kind: Literal['user-prompt'] = 'user-prompt' 

247 """Part type identifier, this is available on all parts as a discriminator.""" 

248 

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'}) 

256 

257 

258tool_return_ta: pydantic.TypeAdapter[Any] = pydantic.TypeAdapter(Any, config=pydantic.ConfigDict(defer_build=True)) 

259 

260 

261@dataclass 

262class ToolReturnPart: 

263 """A tool return message, this encodes the result of running a tool.""" 

264 

265 tool_name: str 

266 """The name of the "tool" was called.""" 

267 

268 content: Any 

269 """The return value.""" 

270 

271 tool_call_id: str 

272 """The tool call identifier, this is used by some models including OpenAI.""" 

273 

274 timestamp: datetime = field(default_factory=_now_utc) 

275 """The timestamp, when the tool returned.""" 

276 

277 part_kind: Literal['tool-return'] = 'tool-return' 

278 """Part type identifier, this is available on all parts as a discriminator.""" 

279 

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() 

286 

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')} 

294 

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 ) 

300 

301 

302error_details_ta = pydantic.TypeAdapter(list[pydantic_core.ErrorDetails], config=pydantic.ConfigDict(defer_build=True)) 

303 

304 

305@dataclass 

306class RetryPromptPart: 

307 """A message back to a model asking it to try again. 

308 

309 This can be sent for a number of reasons: 

310 

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 """ 

320 

321 content: list[pydantic_core.ErrorDetails] | str 

322 """Details of why and how the model should retry. 

323 

324 If the retry was triggered by a [`ValidationError`][pydantic_core.ValidationError], this will be a list of 

325 error details. 

326 """ 

327 

328 tool_name: str | None = None 

329 """The name of the tool that was called, if any.""" 

330 

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. 

333 

334 In case the tool call id is not provided by the model, PydanticAI will generate a random one. 

335 """ 

336 

337 timestamp: datetime = field(default_factory=_now_utc) 

338 """The timestamp, when the retry was triggered.""" 

339 

340 part_kind: Literal['retry-prompt'] = 'retry-prompt' 

341 """Part type identifier, this is available on all parts as a discriminator.""" 

342 

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.' 

351 

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 ) 

365 

366 

367ModelRequestPart = Annotated[ 

368 Union[SystemPromptPart, UserPromptPart, ToolReturnPart, RetryPromptPart], pydantic.Discriminator('part_kind') 

369] 

370"""A message part sent by PydanticAI to a model.""" 

371 

372 

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.""" 

376 

377 parts: list[ModelRequestPart] 

378 """The parts of the user message.""" 

379 

380 kind: Literal['request'] = 'request' 

381 """Message type identifier, this is available on all parts as a discriminator.""" 

382 

383 

384@dataclass 

385class TextPart: 

386 """A plain text response from a model.""" 

387 

388 content: str 

389 """The text content of the response.""" 

390 

391 part_kind: Literal['text'] = 'text' 

392 """Part type identifier, this is available on all parts as a discriminator.""" 

393 

394 def has_content(self) -> bool: 

395 """Return `True` if the text content is non-empty.""" 

396 return bool(self.content) 

397 

398 

399@dataclass 

400class ToolCallPart: 

401 """A tool call from a model.""" 

402 

403 tool_name: str 

404 """The name of the tool to call.""" 

405 

406 args: str | dict[str, Any] 

407 """The arguments to pass to the tool. 

408 

409 This is stored either as a JSON string or a Python dictionary depending on how data was received. 

410 """ 

411 

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. 

414 

415 In case the tool call id is not provided by the model, PydanticAI will generate a random one. 

416 """ 

417 

418 part_kind: Literal['tool-call'] = 'tool-call' 

419 """Part type identifier, this is available on all parts as a discriminator.""" 

420 

421 def args_as_dict(self) -> dict[str, Any]: 

422 """Return the arguments as a Python dictionary. 

423 

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) 

431 

432 def args_as_json_str(self) -> str: 

433 """Return the arguments as a JSON string. 

434 

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() 

440 

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) 

449 

450 

451ModelResponsePart = Annotated[Union[TextPart, ToolCallPart], pydantic.Discriminator('part_kind')] 

452"""A message part returned by a model.""" 

453 

454 

455@dataclass 

456class ModelResponse: 

457 """A response from a model, e.g. a message from the model to the PydanticAI app.""" 

458 

459 parts: list[ModelResponsePart] 

460 """The parts of the model message.""" 

461 

462 model_name: str | None = None 

463 """The name of the model that generated the response.""" 

464 

465 timestamp: datetime = field(default_factory=_now_utc) 

466 """The timestamp of the response. 

467 

468 If the model provides a timestamp in the response (as OpenAI does) that will be used. 

469 """ 

470 

471 kind: Literal['response'] = 'response' 

472 """Message type identifier, this is available on all parts as a discriminator.""" 

473 

474 def otel_events(self) -> list[Event]: 

475 """Return OpenTelemetry events for the response.""" 

476 result: list[Event] = [] 

477 

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 

483 

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 

501 

502 return result 

503 

504 

505ModelMessage = Annotated[Union[ModelRequest, ModelResponse], pydantic.Discriminator('kind')] 

506"""Any message sent to or returned by a model.""" 

507 

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.""" 

512 

513 

514@dataclass 

515class TextPartDelta: 

516 """A partial update (delta) for a `TextPart` to append new text content.""" 

517 

518 content_delta: str 

519 """The incremental text content to add to the existing `TextPart` content.""" 

520 

521 part_delta_kind: Literal['text'] = 'text' 

522 """Part delta type identifier, used as a discriminator.""" 

523 

524 def apply(self, part: ModelResponsePart) -> TextPart: 

525 """Apply this text delta to an existing `TextPart`. 

526 

527 Args: 

528 part: The existing model response part, which must be a `TextPart`. 

529 

530 Returns: 

531 A new `TextPart` with updated text content. 

532 

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) 

539 

540 

541@dataclass 

542class ToolCallPartDelta: 

543 """A partial update (delta) for a `ToolCallPart` to modify tool name, arguments, or tool call ID.""" 

544 

545 tool_name_delta: str | None = None 

546 """Incremental text to add to the existing tool name, if any.""" 

547 

548 args_delta: str | dict[str, Any] | None = None 

549 """Incremental data to add to the tool arguments. 

550 

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 """ 

554 

555 tool_call_id: str | None = None 

556 """Optional tool call identifier, this is used by some models including OpenAI. 

557 

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.""" 

560 

561 part_delta_kind: Literal['tool_call'] = 'tool_call' 

562 """Part delta type identifier, used as a discriminator.""" 

563 

564 def as_part(self) -> ToolCallPart | None: 

565 """Convert this delta to a fully formed `ToolCallPart` if possible, otherwise return `None`. 

566 

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 

572 

573 return ToolCallPart(self.tool_name_delta, self.args_delta, self.tool_call_id or _generate_tool_call_id()) 

574 

575 @overload 

576 def apply(self, part: ModelResponsePart) -> ToolCallPart: ... 

577 

578 @overload 

579 def apply(self, part: ModelResponsePart | ToolCallPartDelta) -> ToolCallPart | ToolCallPartDelta: ... 

580 

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. 

583 

584 Args: 

585 part: The existing model response part or delta to update. 

586 

587 Returns: 

588 Either a new `ToolCallPart` or an updated `ToolCallPartDelta`. 

589 

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) 

596 

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) 

599 

600 raise ValueError(f'Can only apply ToolCallPartDeltas to ToolCallParts or ToolCallPartDeltas, not {part}') 

601 

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) 

608 

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) 

623 

624 if self.tool_call_id: 

625 delta = replace(delta, tool_call_id=self.tool_call_id) 

626 

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()) 

630 

631 return delta 

632 

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) 

639 

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) 

650 

651 if self.tool_call_id: 

652 part = replace(part, tool_call_id=self.tool_call_id) 

653 return part 

654 

655 

656ModelResponsePartDelta = Annotated[Union[TextPartDelta, ToolCallPartDelta], pydantic.Discriminator('part_delta_kind')] 

657"""A partial update (delta) for any model response part.""" 

658 

659 

660@dataclass 

661class PartStartEvent: 

662 """An event indicating that a new part has started. 

663 

664 If multiple `PartStartEvent`s are received with the same index, 

665 the new one should fully replace the old one. 

666 """ 

667 

668 index: int 

669 """The index of the part within the overall response parts list.""" 

670 

671 part: ModelResponsePart 

672 """The newly started `ModelResponsePart`.""" 

673 

674 event_kind: Literal['part_start'] = 'part_start' 

675 """Event type identifier, used as a discriminator.""" 

676 

677 

678@dataclass 

679class PartDeltaEvent: 

680 """An event indicating a delta update for an existing part.""" 

681 

682 index: int 

683 """The index of the part within the overall response parts list.""" 

684 

685 delta: ModelResponsePartDelta 

686 """The delta to apply to the specified part.""" 

687 

688 event_kind: Literal['part_delta'] = 'part_delta' 

689 """Event type identifier, used as a discriminator.""" 

690 

691 

692@dataclass 

693class FinalResultEvent: 

694 """An event indicating the response to the current model request matches the result schema.""" 

695 

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.""" 

702 

703 

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.""" 

706 

707AgentStreamEvent = Annotated[ 

708 Union[PartStartEvent, PartDeltaEvent, FinalResultEvent], pydantic.Discriminator('event_kind') 

709] 

710"""An event in the agent stream.""" 

711 

712 

713@dataclass 

714class FunctionToolCallEvent: 

715 """An event indicating the start to a call to a function tool.""" 

716 

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.""" 

723 

724 def __post_init__(self): 

725 self.call_id = self.part.tool_call_id or str(uuid.uuid4()) 

726 

727 

728@dataclass 

729class FunctionToolResultEvent: 

730 """An event indicating the result of a function tool call.""" 

731 

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.""" 

738 

739 

740HandleResponseEvent = Annotated[Union[FunctionToolCallEvent, FunctionToolResultEvent], pydantic.Discriminator('kind')]