Skip to content

Python: fix(vectors): compare in-memory filters in the field's own type - #9574

Open
H23 (Madin-H23) wants to merge 9 commits into
microsoft:mainfrom
Madin-H23:fix/in-memory-filter-native-scalars
Open

H23 (Madin-H23) wants to merge 9 commits into
microsoft:mainfrom
Madin-H23:fix/in-memory-filter-native-scalars

Conversation

@Madin-H23

@Madin-H23 H23 (Madin-H23) commented Oct 21, 2026 •

Copy link
Copy Markdown

Motivation & Context

InMemoryCollection stores records through the shared serializer, so a native scalar such as a datetime is held as its serialized value. A filter operand arrives native, and the two sides were compared as they were:

@vectorstoremodel
@dataclass
class Record:
    id: Annotated[str, VectorStoreField("key")]
    created: Annotated[datetime, VectorStoreField("data")]
    embedding: Annotated[list[float], VectorStoreField("vector", dimensions=2)] = field(default_factory=list)

value = datetime(2026, 10, 16, 1, 0, tzinfo=timezone.utc)
collection = InMemoryCollection(Record, collection_name="datetime-filter-repro")
await collection.ensure_collection_exists()
await collection.upsert([Record("one", value, [1.0, 0.0])], generate_vectors=False)

await collection.get(keys=["one"])                              # -> the record
await collection.get(filter=Filter("created", "eq", value))     # -> []
await collection.get(filter=Filter("created", "gte", value))    # -> ValueError

The key lookup restores the field's original Python type, so the record is there; the filter comparison compares the serialized value with the native one, so an equality filter matches nothing and an ordered comparison raises. The connector accepts these operand types — date, datetime, time, timedelta, Decimal and UUID are all in _SCALAR_FILTER_TYPES — so the filter is accepted and then silently finds nothing.

Description & Review Guide

  • What are the major changes?

    The in-memory connector compares a filter operand in the type the field declares, in two parts.

    1. The stored value is restored to the field's declared type. A record is stored through the shared serializer, so a datetime is held as an ISO timestamp, a timedelta as an ISO 8601 duration, and a UUID or a Decimal as text. Before the comparison the stored value is parsed back with the standard library, so the comparison runs in the field's own semantics and the operand stays untouched. The stored forms are exactly the ones msgspec.to_builtins writes — 2026-10-16T01:00:00Z, PT7200S, the UUID text, the decimal text — so the parse is the inverse of the write.

    2. An operand that cannot be compared in the field's own type is rejected. The operand validator accepts every native scalar, but a str operand for a datetime field, a datetime operand for a str field, or an int operand for a Decimal field cannot be compared in the field's semantics: the connector does not coerce between types. Such an operand is now rejected with a TypeError that names the field, its declared type and the operand type, instead of quietly matching no record. The rejection covers the operators that compare an operand against the field value (eq, ne, gt, gte, lt, lte, between, in, not_in), every operand of a collection filter, and every leaf of a filter group.

      The check is deliberately limited to the scalar types the serializer writes as text (date, datetime, time, timedelta, Decimal, UUID). Outside that set the stored value is the native value and the comparison is ordinary Python semantics, which the framework already defines — a boolean operand never equals a number, for example — so those comparisons are left alone.

    The restore is scoped to the in-memory connector, because it is the one that keeps the serialized form; a store that holds native values is unaffected. Only the fields the filter actually references are restored, and a field whose type is not declared is left as it is.

  • What is the impact of these changes?

    An equality filter with the same native value now matches the record it was stored from, and an ordered comparison compares numbers and durations as such — a decimal of 10 sorts after a decimal of 2, which the serialized form would not, and two timestamps that are the same instant match even when they are written with different UTC offsets. Operands that the serializer leaves unchanged — strings, integers, booleans — behave exactly as before, and a filter for a different value still does not match. An operand that cannot be compared in the field's type is now rejected explicitly rather than silently matching nothing, which is the other option the issue allows.

  • What do you want reviewers to focus on?

    Where the comparison happens, and where the rejection is placed. The operands are never rewritten, so a filter that compares against the serialized form of a value keeps its previous behaviour; what changes is that a native operand is now compared against a native value, and that an operand of the wrong type no longer passes silently. The parse of the stored form is deliberately limited to the scalar types the serializer writes, and a value that does not parse is left untouched rather than raising. The rejection reads the field's declared type, so a field whose type is not declared — an explicit VectorStoreCollectionDefinition without type_ — is not restricted. An operand has to be an instance of the declared type: the connector does not coerce, so an int operand for a Decimal field is rejected as well.

Related Issue

Fixes #9438

The direction follows the expectation stated in the issue: compare the operand in the field's own semantics, and reject an operand whose semantics cannot be preserved instead of returning no matches. The issue does not carry the ready-for-implementation label yet; we asked the maintainer to add it on the issue and said there that we will close this pull request if no confirmation arrives by 2026-10-27.

AI Assistance

  • No material AI assistance was used.
  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: the issue analysis, the implementation in agent_framework/_in_memory.py, the regression tests, and this description were drafted with an AI coding agent (MiniMax M3.1 Flash via the ZCode CLI) under the repository's AI-Assisted Contributions Policy. What I verified myself: on the unmodified file the equality filter returns no record, the ordered comparison raises ValueError: Filter operator 'gte' cannot compare field 'created' with value datetime.datetime(...), and the key lookup returns the record; the new tests fail there and pass with the change. A case with a decimal of 2 and a decimal of 10 shows that the comparison is numeric and not textual, and a case with a timestamp written in another UTC offset shows that the comparison follows the instant and not the written offset. The rejection tests fail on the previous commit of this pull request and pass with it, and they fail on the unmodified file because the operand was accepted and matched nothing. The whole packages/core/tests/core/test_in_memory.py file passes, and the full packages/core/tests run has the same failures before and after the change, all of them in the MCP and aiohttp areas that need optional dependencies this environment does not have; ruff check and ruff format --check with the pinned 0.17.0 are clean, and pyright reports no new diagnostics.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

… representation

Closes microsoft#9438

In-memory records are stored through the shared serializer, so a native
scalar such as a datetime is held as its serialized value. A filter operand
arrives native, and the two sides were compared as they were: an equality
filter matched nothing and an ordered comparison raised, even though the
connector accepts those operand types.

The operand now goes through the same serializer before the comparison, so
both sides are in the stored representation. The filter tree is rebuilt with
the normalized operands, so nested groups are covered too. The change is
scoped to the in-memory connector, which is the one that keeps the
serialized form; the other connectors store native values and compare them
natively.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Serialized string comparisons can silently return incorrect numeric and chronological filter results.

1 open issue
What changed in this PR

This PR addresses #9438 by normalizing in-memory filter operands to the representation used for stored records.

Changes:

  • Normalize operands recursively for get and vector search filters.
  • Add native-scalar regression tests and unchanged-scalar controls.
File Description
python/​packages/​core/​tests/​core/​test_in_memory.py Adds scalar equality, ordering, and search tests.
python/​packages/​core/​agent_framework/​_in_memory.py Serializes filter operands before in-memory evaluation.

Review effort: Balanced

Comment on lines +170 to +174
return msgspec.to_builtins(
value,
str_keys=True,
builtin_types=(bytes, bytearray),
enc_hook=_msgspec_enc_hook,
…ed type

The first version of this change normalized the filter operand to the
representation the records are stored in. That fixed the equality case, but
the comparison then ran on serialized text, so an ordered comparison on a
decimal or a duration compared digits instead of numbers.

The stored value is now restored to the type its field declares before the
comparison, so the comparison runs in the field's own semantics and the
operands stay untouched. The restore is scoped to the in-memory connector,
which is the one that keeps the serialized form; a store that holds native
values is unaffected.
@Madin-H23

Copy link
Copy Markdown
Author

Good catch — the review is right, and the first version of this change traded a loud error for a silently wrong result.

Normalizing the operand to the stored representation does fix the equality case, but the comparison then runs on serialized text, so an ordered comparison on a decimal or a duration compares digits instead of numbers: a stored 10 would sort before a stored 2.

The second commit replaces that with the other side of the mismatch. The stored value is restored to the type its field declares before the comparison, so the comparison runs in the field's own semantics and the operands are never rewritten. A decimal of 10 is compared as a number, a timestamp as a timestamp, and a filter that compares against the serialized form of a value keeps its previous behaviour.

The restore is scoped to the in-memory connector, which is the one that keeps the serialized form; a store that holds native values is unaffected. Only the fields the filter actually references are restored, and a field whose type is not one of the scalars the shared serializer writes is left as it is rather than raising.

A new regression case covers the difference directly: two records with the decimals 2 and 10, filtered with gte 10, must select the record with 10 — the serialized form sorts the other way round as text. It fails against the first version of this pull request and passes against the current one.

packages/core/tests/core/test_in_memory.py passes in full, the full packages/core/tests run has the same failures before and after the change (all in the MCP and aiohttp areas that need optional dependencies this environment does not have), and ruff and pyright are clean on the changed file.

The previous commit restored the stored value to the type its field declares,
so the comparison runs in the field's own semantics. The issue allows the
other option as well: an operand type whose semantics cannot be preserved
should be rejected explicitly rather than silently returning no matches.

The operand validator accepts every native scalar, so a string operand for a
datetime field, a datetime operand for a string field, or an integer operand
for a decimal field was accepted and then matched nothing. Such an operand is
now rejected with a TypeError that names the field, its declared type and the
operand type.

The check is limited to the scalar types the shared serializer writes as
text, because outside that set the stored value is the native value and the
comparison keeps the ordinary Python semantics the framework already defines.
@Madin-H23

Copy link
Copy Markdown
Author

The second half of the rework is pushed as a296683.

The review pointed at two gaps: the ordered comparison ran on the serialized form, and the issue also allows rejecting an operand whose semantics cannot be preserved. The first commit (da28852) fixed the comparison by restoring the stored value to the type its field declares. This commit adds the rejection:

  • An operand whose type does not match the declared type of the field it is compared against now raises a TypeError naming the field, its declared type and the operand type, instead of being accepted and then matching nothing.
  • The check covers the operators that compare an operand against the field value (eq, ne, gt, gte, lt, lte, between, in, not_in), every operand of a collection filter, and every leaf of a filter group. The get path and the search path behave the same way.
  • It is limited to the scalar types the shared serializer writes as text (date, datetime, time, timedelta, Decimal, UUID), because outside that set the stored value is already the native value and the comparison keeps the ordinary Python semantics the framework already defines. A field whose type is not declared is not restricted.

New regressions, on top of the ones already in the pull request:

  • A decimal compared against a different decimal — Decimal("2") must not match a stored Decimal("10"), and the ordered comparison stays numeric.
  • Two timestamps that are the same instant written with different UTC offsets — they must match, and the order of two timestamps must not depend on the offset they are written with.
  • The rejected operands: a foreign operand for a native-scalar field, a native-scalar operand for another field, an operand inside a collection filter, an operand inside a filter group, and the search path.

On the unmodified file all of the new cases fail, because the operand was accepted and then matched nothing. On the previous commit of this pull request the rejection cases fail while the comparison cases pass, which separates the two halves. With this commit the whole packages/core/tests/core/test_in_memory.py file passes, the full packages/core/tests run has the same failures before and after the change (all in the MCP and aiohttp areas that need optional dependencies this environment does not have), ruff check and ruff format --check with the pinned 0.17.0 are clean, and pyright reports no new diagnostics.

@Madin-H23 H23 (Madin-H23) changed the title Python: fix(vectors): compare in-memory filter operands in the stored representation Python: fix(vectors): compare in-memory filters in the field's own type Oct 22, 2026
`pyupgrade` runs with `--py310-plus` in the repository's pre-commit hooks, so it
rewrites `Callable` out of the `typing` import. The import the previous commit
added therefore failed the Pre-commit Hooks check, and `merge-gatekeeper` with it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three correctness issues should be addressed before merge.

continue
storage_name = field.storage_name or field.name
if storage_name in restored:
restored[storage_name] = _restore_in_memory_value(restored[storage_name], field.type_)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restoring the field here without considering the operator breaks the existing text operators for these serialized scalar fields. For example, Filter("created", "starts_with", "2026") currently evaluates the stored ISO string, but this converts it to datetime and require_filter_string() then raises. It also means a group that uses both a text operator and a native comparison on the same field cannot be represented by this one record-wide conversion. Please restore/compare per filter leaf (or otherwise preserve text-operator behavior) rather than rewriting the whole record up front.

if match is None:
raise ValueError(f"Unsupported duration '{value}'.")
parts = match.groupdict()
seconds = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs exact arithmetic. Converting the duration components through float loses microseconds for valid large timedeltas (for example, 100000 days + 1µs) and can round timedelta.max past its supported range. Please parse using integer/Decimal microseconds or the existing serializer conversion path, and add precision and maximum-range regression cases.

_RESTORE_IN_MEMORY_VALUE_TYPES: dict[str, Callable[[str], Any]] = {
"datetime": _restore_datetime,
"date": date.fromisoformat,
"time": time.fromisoformat,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

msgspec serializes UTC time values with a trailing Z, but time.fromisoformat() only accepts Z starting in Python 3.11. On supported Python 3.10 this raises, the fallback leaves the stored string unchanged, and equality against the native time silently misses again. Please normalize Z to +00:00 here, as _restore_datetime() does, or use a shared converter, and add a Python-3.10-compatible regression test.

Address review feedback on the in-memory filter restore:

- Evaluate each filter leaf against the record as stored instead of
  rewriting the record up front. A comparison operator restores its own
  leaf's field to the declared type; the text operators keep reading the
  serialized string, so Filter("created", "starts_with", "2026") works
  again and a group can mix a text operator and a native comparison over
  one field.
- Sum the ISO duration as exact decimals of microseconds. A float of the
  total seconds loses microseconds on large durations and rounds
  timedelta.max out of the type's own range.
- Normalize the trailing "Z" the serializer writes for a UTC time
  before time.fromisoformat, which accepts it only from Python 3.11.
@Madin-H23

Copy link
Copy Markdown
Author

All three points are addressed in 27e48d0.

1. Per-leaf restore. The record-wide rewrite is gone. Each comparison leaf now evaluates against the record as stored, restoring only its own field to the declared type; the text operators (starts_with / ends_with / contains_text) keep reading the stored string, so Filter("created", "starts_with", "2026") works again and a group can mix a text operator with a native comparison over one field. The operand-type rejection exempts the text operators, whose string operand is already enforced by the shared filter validation.

2. Exact durations. _restore_timedelta now sums the ISO duration's components as Decimal microseconds and quantizes half-to-even, mirroring timedelta's own rounding, instead of pushing the total seconds through float. Regression cases cover timedelta(days=100_000, microseconds=1) matching exactly, two durations a microsecond apart not collapsing, and timedelta.max restoring instead of rounding out of the type's range - the float path does lose the microsecond on the first and raises days=1000000000 on the last, which is what the new tests catch on the previous head.

3. The Z suffix. _restore_time normalizes the trailing Z to +00:00 exactly as _restore_datetime does. One honest note: on this machine (3.12) the previous head happened to pass the time regression because 3.12's time.fromisoformat already accepts Z - the case only fails on the supported 3.10 leg, which is where the normalization matters. The other five new cases are red on the previous head here.

test_in_memory and test_vectors: 447 passed; ruff check/format and pyright clean on the touched files.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran on head 27e48d0 and on the merge base 08851e6. The earlier points (lexical comparison, per-leaf restore, exact durations, Z times) are fixed in this head. These two are new.

What I ran:

  • test_in_memory.py on the head: 170 passed.
  • A probe on both commits with a record of count: int = 5, score: float = 2.5, tags: list[UUID] = [U] (probe_9574.py):
                              merge base   PR head
count eq  Decimal("5")        matched 1    TypeError: cannot compare field 'count' of type 'int' ...
count gte Decimal("5")        matched 1    TypeError
score lte Decimal("2.5")      matched 1    TypeError
tags contains UUID            matched 0    matched 0
tags contains str(UUID)       matched 1    matched 1

1. Decimal operands on int and float fields now raise, though they worked on main (_in_memory.py:568-575)

_operand_matches_field_type returns operand_type not in _SERIALIZED_SCALAR_TYPE_NAMES for any field outside the six restored types. So a Decimal operand is rejected for every int or float field. Those fields are stored as native numbers, and 5 == Decimal("5") and 2.5 <= Decimal("2.5") are exact in Python. On main these filters matched. The issue (#9438) lists integer filters as a working control. The earlier review asked to reject only comparisons whose semantics cannot be preserved, and these can be. The parametrized case ("count", Decimal("5")) at tests/core/test_in_memory.py:1093 locks the regression in. Suggestion: for non-restored fields, only reject the non-numeric serialized types (datetime, date, time, timedelta, UUID). Or allow Decimal when the field is int/float.

2. The silent miss is still there for collection fields (_in_memory.py:556-565)

_filter_operand_values returns () for contains, contains_any and contains_all. So the new check never looks at their operands. The per-leaf restore also only handles scalar field types. A list[UUID] field is stored as a list of strings, so Filter("tags", "contains", U) still matches nothing with no error. That's the exact symptom #9438 describes, now on a list field. Either restore elements for list[<restored type>] fields, or extend the rejection to the collection operators so this fails loudly like the scalar case.

Not checked: Python 3.10 (ran on Python 3.14), search path for the list case (the get path above shares the same evaluator), nested field paths.

…s on collection fields

Address review follow-up:

- A field stored as a native value compares exactly with Python's own
  numeric equality and ordering, so a Decimal operand fits int and float
  fields again (the issue lists integer filters as a working control).
  Only the types the serializer writes as text are rejected for them.
- The collection operators now count as operands too: a list[UUID] field
  is stored as a list of strings, so a native UUID operand quietly
  matched nothing and is rejected loudly instead. The stored form of the
  element keeps matching.
@Madin-H23

Copy link
Copy Markdown
Author

Both fixed on the branch (f9a92bf).

1. Numeric operands on native fields. _operand_matches_field_type now rejects, for a field stored as a native value, only the types the serializer writes as text (datetime, date, time, timedelta, UUID). Numeric operands compare exactly with Python's own equality and ordering, so Decimal("5") fits count: int again - the case the issue lists as a working control, which the old parametrize row had locked out. That row moved to a new exact-match test (eq/gte/lte on Decimal("5")), and all three fail on 27e48d0 and pass here.

2. Collection fields. The collection operators now count as operands in the rejection check. The definition keeps only list as the field type (the element type does not survive into it), so instead of restoring elements the check uses what is known: a collection field stores its elements as the serializer writes them, and an operand of a text-stored type (UUID, datetime, ...) can never equal a stored element, so it is rejected loudly - your tags contains UUID probe now raises. The stored form keeps matching: tags contains str(UUID) is pinned as still matching. Both new cases are red on 27e48d0.

test_in_memory + test_vectors: 451 passed; ruff and pyright clean. Noted from your "not checked" list: 3.10 and the search path share this evaluator and validation, and nested field paths stay rejected before any of this runs.

@Somay-kousis

Copy link
Copy Markdown
Contributor

Checked f9a92bf. Both cases from my review are fixed: count eq Decimal("5") matches again and tags contains UUID now raises.

One silent miss is left, from the same two changes meeting. Decimal is stored as text, but it's numeric, so the collection check lets it through:

prices: Annotated[list[Decimal], VectorStoreField("data")]   # stored [Decimal("5")]

Filter("prices", "contains", Decimal("5"))  # matched 0, no error
Filter("prices", "contains", "5")           # matched 1

The stored element is the string "5", so a Decimal or int operand can never match, and nothing says so. Same on 27e48d0, so it isn't a regression, but it's the case the new collection rule is meant to catch. Rejecting Decimal for collection operators doesn't work, because list[int] stores native ints and Filter("prices", "contains", Decimal("5")) on a list[int] field matches today. So the check needs the element type, which the definition drops to list. Keeping the item type from the annotation (or reading it from the model class) would let the check reject Decimal for list[Decimal] and allow it for list[int], and would also let you restore the elements instead of rejecting.

Smaller, for consistency with point 1: the mirror case still raises. Filter("amount", "eq", 5) on amount: Decimal gives TypeError: cannot compare field 'amount' of type 'Decimal' with a filter operand of type 'int', while Decimal("5") against count: int now matches. Decimal("5") == 5 is exact, so an int operand could be allowed for a Decimal field after the restore.

Mirror the numeric-operand allowance across the restore: a Decimal field
restores its stored text to Decimal, and Decimal compares exactly with
int in both equality and ordering, so Filter("amount", "eq", 5) matches
instead of raising.
@Madin-H23

Copy link
Copy Markdown
Author

The mirror case is fixed (96209dc): an int operand now fits a Decimal field. The restore turns the stored text into Decimal, and Decimal compares exactly with int in both equality and ordering, so Filter("amount", "eq", 2) matches, gte/lte are pinned, and the three cases are red on f9a92bf.

On list[Decimal] we agree with the diagnosis, and we would rather not choose the design unilaterally: the root is that _infer_type_name keeps only the origin name, so the definition drops the element type before any connector sees it. Two routes we can see:

  1. keep the fuller type (or store the element type as a separate attribute) in the definition - the check then rejects a Decimal operand for list[Decimal] while allowing it for list[int], and element restoration also becomes possible; but type_ feeds type dispatch in the other connectors, so the string change has blast radius beyond this pull request;
  2. contained to this connector: InMemoryCollection holds the record type, so it can re-read the field annotations and recover the element type for the check (leaving dictionary-record definitions, which carry no annotations, on the current behaviour).

If the maintainers prefer 1, we can take it as a follow-up to this pull request or leave it to them; if 2, say the word and we will put it up here.

@Somay-kousis

Copy link
Copy Markdown
Contributor

Reran on 96209dc: Filter("amount", "eq", 5) on the Decimal field matches now. list[Decimal] contains Decimal("5") still matches 0 with no error, which is the design question you put to the maintainers. Thanks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One remaining case on 96209dc:

Duration restoration uses the caller's Decimal context (_in_memory.py:184-191). Store a record with span: timedelta = timedelta(hours=2), then run:

with localcontext() as ctx:
    ctx.prec = 6
    await collection.get(filter=Filter("span", "eq", timedelta(hours=2)))

This raises IntegrationException: ... decimal.InvalidOperation; with the default precision it returns the record. The microsecond arithmetic and quantize inherit the active precision, so unrelated application Decimal settings can break duration queries. Please use integer arithmetic or an isolated Decimal context with sufficient precision, and add a reduced-precision regression that also checks the caller's context is preserved.

Validation: Python 3.13.16 on macOS; 454 tests passed across test_in_memory.py and test_vectors.py; Ruff 0.17.0 lint and format checks passed on both changed files. The case above was exercised separately through the public collection API. I checked the earlier reviews before these probes; no full-suite, Python 3.10, or live-backend validation is claimed.

A caller that lowers the ambient Decimal precision (for example
localcontext(prec=6)) made the microsecond arithmetic in
_restore_timedelta raise InvalidOperation, so unrelated application
Decimal settings could break duration filters. The sum and the quantize
now run in an isolated context with ample precision, the caller's
context is left untouched, and a reduced-precision regression pins it.
@Madin-H23

Copy link
Copy Markdown
Author

Fixed on the branch (7a5570e): the duration arithmetic now runs in an isolated localcontext() with precision 40, and the caller's context is left exactly as it was.

Why 40: the largest duration the serializer can write needs at most 20 integer digits of microseconds plus the 6 fractional digits, so every product and the sum stay exact well below 40 - the quantize never rounds and no caller rounding mode matters. localcontext() restores the caller's context on exit, and the regression asserts both halves: inside localcontext(prec=6) the filter returns the record and getcontext().prec is still 6, and the default precision is back once the block ends. On 96209dc the same case raises InvalidOperation, as you described.

test_in_memory + test_vectors: 455 passed; ruff check/format and pyright clean on both files. Thanks for running the reduced-precision probe - that is a case none of the earlier reviews covered.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

H23 (@Madin-H23) Maintainer direction: keep this change connector-local for canonical key representations rather than broadening generic list-element coercion in this PR. Please also take a look at the inline UUID-key regression.

if operand is None:
return True
operand_type = type(operand).__name__
if field_type in _SERIALIZED_SCALAR_TYPE_NAMES:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

H23 (@Madin-H23) BaseVectorCollection.key_from_json() intentionally returns UUID keys as canonical strings, and scoped get/delete tools then build an internal Filter("id", "in", validated_keys). The new exact-type check rejects those framework-generated string operands against the declared UUID field, so valid scoped UUID lookups and deletes fail. Please normalize canonical-string UUID key operands before compatibility checking and comparison, while preserving string keys for lookup/deletion, and add regressions for both scoped get and scoped delete tools. Keep the broader list[Decimal] element-type question out of this focused fix unless separately agreed.

…y fields

Scoped get and delete tools address UUID keys through their canonical
string form (key_from_json), and the internal key filter then carries
string operands against the declared UUID field - which the exact-type
check rejected, breaking valid scoped lookups and deletes.

A canonical string operand on a UUID key field now normalizes to UUID
before the compatibility check and the comparison; non-canonical strings
are still rejected loudly. The broader list-element coercion question
stays out of this change.
@Madin-H23

Copy link
Copy Markdown
Author

Fixed on the branch (41aa192), scoped exactly as directed:

  • a canonical string operand on a UUID key field normalizes to UUID before the compatibility check and the comparison, so the framework-generated Filter("id", "in", keys) operands from key_from_json pass and match again;
  • non-canonical strings are still rejected loudly;
  • the broader list[Decimal] element-type question stays out of this change.

Regressions: Filter("id", "in", (canonical,)) matching (red on 7a5570e - it raised), native-UUID operand still matching, and non-canonical strings still rejected. The scoped get/delete tools run this same evaluator through _vector_tool_key_filter, so both paths are covered by the collection-level regression; the key_from_json plumbing itself is unchanged.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

H23 (@Madin-H23) One material regression remains in python/packages/core/agent_framework/_in_memory.py:269-271. The text-operator branch preserves the stored key as a string but still normalizes a UUID-shaped operand into a UUID object. Reconstructing starts_with, ends_with, or contains_text then raises TypeError: Filter value must be a string instead of matching. Please leave text-operator operands unchanged, restrict UUID-key normalization to native-comparison operators, and add regressions for all three text operators through both get and vector search.

This branch was previously deployed

1 inactive deployment
github-app-auth — 41aa1923 Deployed Oct 26, 2026 by Madin-H23 via add_label #26206
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: InMemoryCollection silently misses native scalar filters after record serialization

5 participants