|
29 | 29 | ConfigDict, |
30 | 30 | Field, |
31 | 31 | SkipValidation, |
| 32 | + TypeAdapter, |
32 | 33 | ValidationError, |
33 | 34 | create_model, |
34 | 35 | ) |
|
69 | 70 | import uuid |
70 | 71 | from collections.abc import Sequence |
71 | 72 |
|
| 73 | + from langchain_core.tools.schema import ToolSchema |
| 74 | + |
72 | 75 | FILTERED_ARGS = ("run_manager", "callbacks") |
73 | 76 | TOOL_MESSAGE_BLOCK_TYPES = ( |
74 | 77 | "text", |
@@ -495,65 +498,100 @@ def __init__(self, **kwargs: Any) -> None: |
495 | 498 | def __setattr__(self, name: str, value: object) -> None: |
496 | 499 | """Clear schema caches when schema-influencing fields are mutated.""" |
497 | 500 | if name in self._SCHEMA_INVALIDATING_FIELDS: |
498 | | - self.__dict__.pop("tool_call_schema", None) |
499 | | - self.__dict__.pop("args", None) |
| 501 | + # tool_schema is the single root cache; _inferred_input_schema is |
| 502 | + # kept separate since it's also used outside the tool_schema path. |
| 503 | + self.__dict__.pop("tool_schema", None) |
500 | 504 | self.__dict__.pop("_inferred_input_schema", None) |
501 | | - self.__dict__.pop("_approximate_schema_chars", None) |
502 | 505 | super().__setattr__(name, value) |
503 | 506 |
|
| 507 | + @functools.cached_property |
| 508 | + def tool_schema(self) -> ToolSchema: |
| 509 | + """Unified schema object — the single root cache for this tool's schema. |
| 510 | +
|
| 511 | + Owns input validation (`TypeAdapter`), the JSON schema for LLM APIs, |
| 512 | + the args properties dict, and the approximate char count for token |
| 513 | + estimation. All other schema properties on `BaseTool` delegate here; |
| 514 | + only this property needs to be invalidated on mutation. |
| 515 | +
|
| 516 | + Returns: |
| 517 | + A `ToolSchema` instance for this tool. |
| 518 | + """ |
| 519 | + from langchain_core.tools.schema import ToolSchema # noqa: PLC0415 |
| 520 | + |
| 521 | + # Compute pydantic_schema — the ArgsSchema (model class or dict) for |
| 522 | + # backward compatibility with callers that inspect the type. |
| 523 | + if isinstance(self.args_schema, dict): |
| 524 | + pydantic_schema: ArgsSchema = ( |
| 525 | + {**self.args_schema, "description": self.description} |
| 526 | + if self.description |
| 527 | + else self.args_schema |
| 528 | + ) |
| 529 | + else: |
| 530 | + full_schema = self.get_input_schema() |
| 531 | + fields = [ |
| 532 | + n |
| 533 | + for n, t in get_all_basemodel_annotations(full_schema).items() |
| 534 | + if not _is_injected_arg_type(t) |
| 535 | + ] |
| 536 | + pydantic_schema = _create_subset_model( |
| 537 | + self.name, full_schema, fields, fn_description=self.description |
| 538 | + ) |
| 539 | + |
| 540 | + if isinstance(pydantic_schema, dict): |
| 541 | + json_schema: dict = pydantic_schema |
| 542 | + elif hasattr(pydantic_schema, "model_json_schema"): |
| 543 | + json_schema = pydantic_schema.model_json_schema() |
| 544 | + else: |
| 545 | + json_schema = pydantic_schema.schema() # type: ignore[deprecated] # pydantic v1 |
| 546 | + args = cast("dict", json_schema.get("properties", {})) |
| 547 | + payload = { |
| 548 | + "name": self.name, |
| 549 | + "description": self.description, |
| 550 | + "schema": json_schema, |
| 551 | + } |
| 552 | + approximate_chars = len(json.dumps(payload, default=str)) |
| 553 | + |
| 554 | + return ToolSchema( |
| 555 | + name=self.name, |
| 556 | + description=self.description or "", |
| 557 | + validator=TypeAdapter(self.get_input_schema()), |
| 558 | + json_schema=json_schema, |
| 559 | + pydantic_schema=pydantic_schema, |
| 560 | + args=args, |
| 561 | + approximate_chars=approximate_chars, |
| 562 | + ) |
| 563 | + |
504 | 564 | @property |
505 | | - def is_single_input(self) -> bool: |
506 | | - """Check if the tool accepts only a single input argument. |
| 565 | + def tool_call_schema(self) -> ArgsSchema: |
| 566 | + """The schema for tool calls, excluding injected arguments. |
507 | 567 |
|
508 | 568 | Returns: |
509 | | - `True` if the tool has only one input argument, `False` otherwise. |
| 569 | + The schema used for tool calls from language models. |
510 | 570 | """ |
511 | | - keys = {k for k in self.args if k != "kwargs"} |
512 | | - return len(keys) == 1 |
| 571 | + return self.tool_schema.pydantic_schema # type: ignore[no-any-return] |
513 | 572 |
|
514 | | - @functools.cached_property |
| 573 | + @property |
515 | 574 | def args(self) -> dict: |
516 | | - """Get the tool's input arguments schema. |
| 575 | + """The tool's input argument properties. |
517 | 576 |
|
518 | 577 | Returns: |
519 | 578 | `dict` containing the tool's argument properties. |
520 | 579 | """ |
521 | | - if isinstance(self.args_schema, dict): |
522 | | - json_schema = self.args_schema |
523 | | - elif self.args_schema and issubclass(self.args_schema, BaseModelV1): |
524 | | - json_schema = self.args_schema.schema() |
525 | | - else: |
526 | | - input_schema = self.tool_call_schema |
527 | | - if isinstance(input_schema, dict): |
528 | | - json_schema = input_schema |
529 | | - else: |
530 | | - json_schema = input_schema.model_json_schema() |
531 | | - return cast("dict", json_schema["properties"]) |
| 580 | + return self.tool_schema.args |
532 | 581 |
|
533 | | - @functools.cached_property |
534 | | - def tool_call_schema(self) -> ArgsSchema: |
535 | | - """Get the schema for tool calls, excluding injected arguments. |
| 582 | + @property |
| 583 | + def _approximate_schema_chars(self) -> int: |
| 584 | + return self.tool_schema.approximate_chars |
| 585 | + |
| 586 | + @property |
| 587 | + def is_single_input(self) -> bool: |
| 588 | + """Check if the tool accepts only a single input argument. |
536 | 589 |
|
537 | 590 | Returns: |
538 | | - The schema that should be used for tool calls from language models. |
| 591 | + `True` if the tool has only one input argument, `False` otherwise. |
539 | 592 | """ |
540 | | - if isinstance(self.args_schema, dict): |
541 | | - if self.description: |
542 | | - return { |
543 | | - **self.args_schema, |
544 | | - "description": self.description, |
545 | | - } |
546 | | - |
547 | | - return self.args_schema |
548 | | - |
549 | | - full_schema = self.get_input_schema() |
550 | | - fields = [] |
551 | | - for name, type_ in get_all_basemodel_annotations(full_schema).items(): |
552 | | - if not _is_injected_arg_type(type_): |
553 | | - fields.append(name) |
554 | | - return _create_subset_model( |
555 | | - self.name, full_schema, fields, fn_description=self.description |
556 | | - ) |
| 593 | + keys = {k for k in self.args if k != "kwargs"} |
| 594 | + return len(keys) == 1 |
557 | 595 |
|
558 | 596 | @functools.cached_property |
559 | 597 | def _injected_args_keys(self) -> frozenset[str]: |
@@ -583,18 +621,6 @@ def _inferred_input_schema(self) -> type[BaseModel]: |
583 | 621 | """Schema inferred from `_run` signature; computed once.""" |
584 | 622 | return create_schema_from_function(self.name, self._run) |
585 | 623 |
|
586 | | - @functools.cached_property |
587 | | - def _approximate_schema_chars(self) -> int: |
588 | | - """Cached char count of the neutral tool payload for token estimation.""" |
589 | | - schema = self.tool_call_schema |
590 | | - schema_dict = schema if isinstance(schema, dict) else schema.model_json_schema() |
591 | | - payload = { |
592 | | - "name": self.name, |
593 | | - "description": self.description, |
594 | | - "schema": schema_dict, |
595 | | - } |
596 | | - return len(json.dumps(payload, default=str)) |
597 | | - |
598 | 624 | @override |
599 | 625 | def invoke( |
600 | 626 | self, |
|
0 commit comments