{"record":{"id":"dad76dc2a409488c","repo":"bytedance/deer-flow","slug":"retrieval-category-filter-must-be-a-string","errorCode":null,"errorMessage":"retrieval category filter must be a string","messagePattern":"retrieval category filter must be a string","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/retrieval.py","lineNumber":626,"sourceCode":"\n    def search(\n        self,\n        query: str,\n        *,\n        scopes: list[dict[str, str | None]],\n        top_k: int,\n        mode: str,\n        filters: dict[str, Any] | None,\n    ) -> list[dict[str, Any]]:\n        if not query.strip() or top_k <= 0:\n            return []\n        if mode not in {\"hybrid\", \"fts5\", \"lexical\"}:\n            raise ValueError(f\"unsupported FTS5 retrieval mode: {mode}\")\n\n        filters = filters or {}\n        category = filters.get(\"category\")\n        if category is not None and not isinstance(category, str):\n            raise ValueError(\"retrieval category filter must be a string\")\n\n        results: list[dict[str, Any]] = []\n        per_scope_limit = top_k * 4\n        for scope in scopes:\n            scope_user, scope_agent = _scope_key(scope)\n            for candidate in self._engine.search(\n                query,\n                scope_user=scope_user,\n                scope_agent=scope_agent,\n                category=category,\n                top_k=per_scope_limit,\n            ):\n                fact = dict(candidate)\n                score = float(fact.pop(\"score\", 0.0))\n                bm25_score = float(fact.pop(\"bm25_score\", 0.0))\n                if any(fact.get(key) != value for key, value in filters.items()):\n                    continue\n                results.append(","sourceCodeStart":608,"sourceCodeEnd":644,"githubUrl":"https://github.com/bytedance/deer-flow/blob/1dd6ba1acb03700589994b0366c5d1c7d05e2eff/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/retrieval.py#L608-L644","documentation":"Raised by the FTS5 retrieval path in deermem when a caller passes a filters dict whose 'category' value is present but not a string. The engine's SQL search binds category as a text parameter, so non-string values (int, list, dict, bool) are rejected before querying. This is an input-contract error on the public search/retrieve API surface.","triggerScenarios":"Calling memory search/retrieve with filters={'category': 5}, filters={'category': ['preference']}, or filters={'category': True}. Any code that forwards untyped user or LLM tool output straight into the filters argument of the retrieval backend hits this immediately.","commonSituations":"LLM tool-calls that emit JSON numbers or arrays where the schema expects a single string; frontends forwarding a multi-select category picker as a list; refactors that changed the filter value type without updating callers.","solutions":["Coerce or reject the category before calling search: use a single string, e.g. filters={'category': str(value)} only after confirming it is scalar.","If the caller has a list of categories, issue one search per category and merge/limit results, since the backend accepts exactly one.","Validate the whole filters dict against the documented schema ({category?: str}) at the API boundary and return a 4xx to the client instead of letting the backend raise."],"exampleFix":"// before\nresults = memory.search(query=\"user preferences\", top_k=5, mode=\"hybrid\", filters={\"category\": [\"preference\", \"context\"]})\n# after (python)\nresults = []\nfor category in [\"preference\", \"context\"]:\n    results.extend(memory.search(query=\"user preferences\", top_k=5, mode=\"hybrid\", filters={\"category\": category}))","handlingStrategy":"validation","validationCode":"category = (filters or {}).get(\"category\")\nif category is not None and not isinstance(category, str):\n    if isinstance(category, (list, tuple)):\n        raise TypeError(\"run one search per category; backend accepts a single string\")\n    category = str(category)\nfilters = {\"category\": category} if category is not None else {}","typeGuard":"from typing import Any\n\ndef is_valid_category_filter(filters: dict[str, Any] | None) -> bool:\n    category = (filters or {}).get(\"category\")\n    return category is None or isinstance(category, str)","tryCatchPattern":"try:\n    results = backend.search(query, top_k=5, mode=\"hybrid\", filters=filters)\nexcept ValueError as exc:\n    if \"category filter\" in str(exc):\n        raise  # fix the filter shape upstream; surface 4xx to the client\n    raise","preventionTips":["Type-check filters at the API boundary before forwarding to the retrieval backend.","Never pass multi-select category arrays; loop categories and merge results.","Keep a typed schema (TypedDict/dataclass) for the filters argument in caller code."],"tags":["deermem","memory","validation","fts5"],"backgroundTag":null,"analyzedSha":"1dd6ba1acb03700589994b0366c5d1c7d05e2eff","analyzedAt":"2026-08-14T21:20:34.804Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}