Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions libs/core/langchain_core/indexing/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,15 +293,22 @@ def update(
ids.
ValueError: If time_at_least is in the future.
"""

if group_ids and len(keys) != len(group_ids):
msg = "Length of keys must match length of group_ids"
raise ValueError(msg)
# Fetch the timestamp once per batch instead of once per key so that
# all records written in the same update() call share a single,
# consistent timestamp. Calling get_time() per-key allowed the
# timestamp to drift across a large batch, which caused
# list_keys(before=...) to miss records during cleanup (see #39087).
update_time = self.get_time()
if time_at_least and time_at_least > update_time:
msg = "time_at_least must be in the past"
raise ValueError(msg)
for index, key in enumerate(keys):
group_id = group_ids[index] if group_ids else None
if time_at_least and time_at_least > self.get_time():
msg = "time_at_least must be in the past"
raise ValueError(msg)
self.records[key] = {"group_id": group_id, "updated_at": self.get_time()}
self.records[key] = {"group_id": group_id, "updated_at": update_time}

async def aupdate(
self,
Expand Down
49 changes: 14 additions & 35 deletions libs/core/langchain_core/utils/function_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,13 @@ def _rm_titles(kv: dict[str, Any], prev_key: str = "") -> dict[str, Any]:

for k, v in kv.items():
if k == "title":
# If the value is a nested dict and part of a property under "properties",
# preserve the title but continue recursion
if isinstance(v, dict) and prev_key == "properties":
new_kv[k] = _rm_titles(v, k)
else:
# Otherwise, remove this "title" key
continue
elif isinstance(v, dict):
# Recurse into nested dictionaries
new_kv[k] = _rm_titles(v, k)
else:
# Leave non-dict values untouched
new_kv[k] = v

return new_kv
Expand Down Expand Up @@ -266,13 +261,9 @@ def _convert_any_typed_dicts_to_pydantic(
if is_typeddict(type_):
typed_dict = type_
docstring = inspect.getdoc(typed_dict)
# Use get_type_hints to properly resolve forward references and
# string annotations in Python 3.14+ (PEP 649 deferred annotations).
# include_extras=True preserves Annotated metadata.
try:
annotations_ = get_type_hints(typed_dict, include_extras=True)
except Exception:
# Fallback for edge cases where get_type_hints might fail
annotations_ = typed_dict.__annotations__
description, arg_descriptions = _parse_google_docstring(
docstring, list(annotations_)
Expand Down Expand Up @@ -356,11 +347,6 @@ def _format_tool_to_openai_function(tool: BaseTool) -> FunctionDescription:
"name": tool.name,
"description": tool.description,
"parameters": {
# This is a hack to get around the fact that some tools
# do not expose an args_schema, and expect an argument
# which is a string.
# And Open AI does not support an array type for the
# parameters.
"properties": {
"__arg1": {"title": "__arg1", "type": "string"},
},
Expand Down Expand Up @@ -477,7 +463,6 @@ def convert_to_openai_function(
raise ValueError(msg)
oai_function["strict"] = strict
if strict:
# All fields must be `required`
parameters = oai_function.get("parameters")
if isinstance(parameters, dict):
fields = parameters.get("properties")
Expand All @@ -486,9 +471,6 @@ def convert_to_openai_function(
parameters["required"] = list(fields.keys())
oai_function["parameters"] = parameters

# As of 08/06/24, OpenAI requires that additionalProperties be supplied and
# set to False if strict is True.
# All properties layer needs 'additionalProperties=False'
oai_function["parameters"] = _recursive_set_additional_properties_false(
oai_function["parameters"]
)
Expand Down Expand Up @@ -554,13 +536,11 @@ def convert_to_openai_tool(

Added support for OpenAI's image generation built-in tool.
"""
# Import locally to prevent circular import
from langchain_core.tools import Tool # noqa: PLC0415

if isinstance(tool, dict):
if tool.get("type") in _WellKnownOpenAITools:
return tool
# As of 03.12.25 can be "web_search_preview" or "web_search_preview_2025_03_11"
if (tool.get("type") or "").startswith("web_search_preview"):
return tool
if isinstance(tool, Tool) and (tool.metadata or {}).get("type") == "custom_tool":
Expand Down Expand Up @@ -653,11 +633,17 @@ def tool_example_to_messages(
Does not need to be provided.

If not provided, a placeholder value will be inserted.

If provided, must have the same length as `tool_calls`.
ai_response: If provided, content for a final `AIMessage`.

Returns:
A list of messages

Raises:
ValueError: If `tool_outputs` is provided and its length does not match
the number of tool calls.

Examples:
```python
from typing import Optional
Expand Down Expand Up @@ -716,6 +702,14 @@ class Person(BaseModel):
tool_outputs = tool_outputs or ["You have correctly called this tool."] * len(
openai_tool_calls
)
if len(tool_outputs) != len(openai_tool_calls):
msg = (
f"Number of tool outputs ({len(tool_outputs)}) does not match "
f"number of tool calls ({len(openai_tool_calls)}). "
"Either omit `tool_outputs` to use placeholders, or provide "
"exactly one output per tool call."
)
raise ValueError(msg)
for output, tool_call_dict in zip(tool_outputs, openai_tool_calls, strict=False):
messages.append(ToolMessage(content=output, tool_call_id=tool_call_dict["id"]))

Expand Down Expand Up @@ -768,7 +762,6 @@ def _parse_google_docstring(
args_block = block
break
if block.startswith(("Returns:", "Example:")):
# Don't break in case Args come after
past_descriptors = True
elif not past_descriptors:
descriptors.append(block)
Expand All @@ -784,13 +777,6 @@ def _parse_google_docstring(
arg_descriptions: dict[str, str] = {}
if args_block:
arg: str | None = None
# Base indentation, latched once from the first argument line, lets us
# distinguish new argument definitions from continuation lines. This
# assumes Google-style uniform indentation of argument names: a line
# indented deeper than the first argument is treated as a continuation
# (even if it contains a colon), so a more-indented later `name:` line
# in a malformed, non-uniformly-indented block folds into the previous
# argument rather than starting a new one.
arg_indent: int | None = None
for line in args_block.split("\n")[1:]:
if not line.strip():
Expand Down Expand Up @@ -821,20 +807,13 @@ def _recursive_set_additional_properties_false(
schema: dict[str, Any],
) -> dict[str, Any]:
if isinstance(schema, dict):
# Check if 'required' is a key at the current level or if the schema is empty,
# in which case additionalProperties still needs to be specified.
if (
"required" in schema
or ("properties" in schema and not schema["properties"])
# Since Pydantic 2.11, it will always add `additionalProperties: True`
# for arbitrary dictionary schemas
# See: https://pydantic.dev/articles/pydantic-v2-11-release#changes
# If it is already set to True, we need override it to False
or "additionalProperties" in schema
):
schema["additionalProperties"] = False

# Recursively check 'properties' and 'items' if they exist
if "anyOf" in schema:
for sub_schema in schema["anyOf"]:
_recursive_set_additional_properties_false(sub_schema)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,63 @@ async def test_adelete_keys(amanager: InMemoryRecordManager) -> None:
# Check if the deleted keys are no longer in the database
remaining_keys = await amanager.alist_keys()
assert remaining_keys == ["key3"]


def test_update_uses_single_timestamp_for_entire_batch(
manager: InMemoryRecordManager,
) -> None:
"""Regression test for #39087.

update() used to call get_time() once per key inside the loop, so
records written in the same update() call could end up with different
'updated_at' timestamps whenever get_time() drifted between calls.
This test simulates that drift and confirms get_time() is only
consulted once per update() call, so every key in the batch ends up
with the exact same timestamp.
"""
drifting_timestamps = iter(
[
datetime(2021, 1, 1, tzinfo=timezone.utc).timestamp(),
datetime(2021, 1, 2, tzinfo=timezone.utc).timestamp(),
datetime(2021, 1, 3, tzinfo=timezone.utc).timestamp(),
]
)

with patch.object(
manager, "get_time", side_effect=lambda: next(drifting_timestamps)
):
manager.update(["key1", "key2", "key3"])

updated_ats = {
manager.records[key]["updated_at"] for key in ["key1", "key2", "key3"]
}

# Before the fix, this would contain three distinct timestamps
# (one per get_time() call inside the loop).
assert len(updated_ats) == 1
assert updated_ats == {datetime(2021, 1, 1, tzinfo=timezone.utc).timestamp()}


async def test_aupdate_uses_single_timestamp_for_entire_batch(
amanager: InMemoryRecordManager,
) -> None:
"""Async counterpart of test_update_uses_single_timestamp_for_entire_batch."""
drifting_timestamps = iter(
[
datetime(2021, 1, 1, tzinfo=timezone.utc).timestamp(),
datetime(2021, 1, 2, tzinfo=timezone.utc).timestamp(),
datetime(2021, 1, 3, tzinfo=timezone.utc).timestamp(),
]
)

with patch.object(
amanager, "get_time", side_effect=lambda: next(drifting_timestamps)
):
await amanager.aupdate(["key1", "key2", "key3"])

updated_ats = {
amanager.records[key]["updated_at"] for key in ["key1", "key2", "key3"]
}

assert len(updated_ats) == 1
assert updated_ats == {datetime(2021, 1, 1, tzinfo=timezone.utc).timestamp()}
Loading