mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +00:00
* feat(gateway): accept conversation references in run context and report the capability LangGraph SDK clients build a fixed run body and drop unknown top-level fields, so they cannot send the conversation_references field from #5399. RunCreateRequest now lifts context.conversation_references into the top-level field before validation, so it keeps the same bounds and error locations, and drops it from context, so it never reaches the merged run context or the checkpointed configurable. Sending both is a 422. GET /api/features reports conversation_references {enabled, max_references} with the same "tool is configured" predicate as run admission, so a client can hide an entry point on deployments without the tool. Related to #5398. * fix(gateway): report the field type error for a malformed top-level reference list A malformed top-level conversation_references sent alongside a context list now fails with the field's own type error instead of the conflict message. The tool-configured predicate reads tool.use directly, and the features test doubles carry that attribute like every real ToolConfig. * fix(gateway): treat every list-like top-level reference value as a conflict Pydantic's lax mode coerces tuples, sets, frozensets and deques into the list[str] field, so a direct Python caller passing one of those together with context.conversation_references now reports the conflict instead of slipping both grants through. Unreachable over HTTP, where JSON has no such types. * fix(gateway): ask pydantic whether a top-level reference value is list-like Enumerating list-like types cannot track pydantic's lax acceptance set (generators, UserList, dict key views also coerce into list[str]). The conflict guard now validates the top-level value with a TypeAdapter for list[Any]: whatever pydantic would coerce reports the conflict when it is non-empty, and whatever it rejects still surfaces the field's own type error. Regression tests cover deque, UserList, dict keys and a generator, plus rejected scalars. * fix(gateway): probe top-level references with the field's own annotation The conflict guard now validates the top-level value with the exact item annotation the field uses, so its acceptance set is the field's rather than a superset: an item the field rejects (an empty string, a non-string, the ints of a range or dict view) surfaces the field's own item error instead of a conflict. The annotation is shared through one alias so the two cannot drift. * fix(gateway): materialise a one-shot iterator before probing top-level references The item-validating probe could consume a generator while collecting an item error, after which the field re-validated the exhausted iterator, coerced it to [] and let the request through with the key still in context. Iterators are now read once into a list that both the probe and the field validate, so a bad item is reported at its index and a valid generator is kept. * fix(gateway): materialise every once-walkable iterable before probing references Pydantic coerces any iterable into the list field, and an object whose __iter__ hands out a generator once is not an Iterator instance, so the previous gate let it reach the probe and be consumed. The lift now reads every iterable except lists, tuples and the shapes the field rejects as a whole (str, bytes, dict) into a list first, so the probe and the field always validate the same items. --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
179 lines
9.2 KiB
Python
179 lines
9.2 KiB
Python
"""Shared request models for the LangGraph-compatible run boundary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
from typing import Annotated, Any, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, ValidationInfo, field_validator, model_validator
|
|
from pydantic_core import PydanticCustomError
|
|
|
|
from deerflow.runtime.stream_modes import RunStreamMode, UnsupportedStreamModeError, normalize_stream_modes
|
|
from deerflow.utils.thread_id import validate_thread_id
|
|
|
|
# Upper bound on explicit conversation references per run; ``/api/features``
|
|
# reports it so a UI can cap its selection to the same number.
|
|
MAX_CONVERSATION_REFERENCES = 3
|
|
|
|
# One reference as the field accepts it; the conflict guard probes with the
|
|
# same annotation so its acceptance set is exactly the field's, now and after
|
|
# a pydantic upgrade (lax mode also coerces tuples, sets, generators, ...).
|
|
ConversationReference = Annotated[str, Field(strict=True, min_length=1, max_length=2048)]
|
|
_REFERENCES_ADAPTER = TypeAdapter(list[ConversationReference])
|
|
# Inputs the lift never materialises: lists and tuples can be read again, and
|
|
# str, bytes and dict are rejected by the field as a whole (``list_type``), a
|
|
# verdict the field must keep reporting itself.
|
|
_READ_MANY_TIMES = (list, tuple, str, bytes, bytearray, dict)
|
|
|
|
|
|
class RunCreateRequest(BaseModel):
|
|
"""Validated run request used by both HTTP and internal launch paths."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
assistant_id: str | None = Field(default=None, description="Agent / assistant to use")
|
|
input: dict[str, Any] | None = Field(default=None, description="Graph input (e.g. {messages: [...]})")
|
|
command: dict[str, Any] | None = Field(default=None, description="LangGraph Command")
|
|
metadata: dict[str, Any] | None = Field(default=None, description="Run metadata")
|
|
config: dict[str, Any] | None = Field(default=None, description="RunnableConfig overrides")
|
|
context: dict[str, Any] | None = Field(default=None, description="DeerFlow context overrides (model_name, thinking_enabled, etc.)")
|
|
conversation_references: list[ConversationReference] = Field(
|
|
default_factory=list,
|
|
max_length=MAX_CONVERSATION_REFERENCES,
|
|
description="Explicit thread IDs or same-origin chat URLs readable only during this run (opt-in read_conversation tool); SDK clients may send the same list as context.conversation_references",
|
|
)
|
|
webhook: None = Field(default=None, description="Compatibility placeholder; completion callbacks are not supported")
|
|
checkpoint_id: str | None = Field(default=None, description="Resume from checkpoint")
|
|
checkpoint: dict[str, Any] | None = Field(default=None, description="Full checkpoint object")
|
|
interrupt_before: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt before")
|
|
interrupt_after: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt after")
|
|
stream_mode: list[RunStreamMode] | RunStreamMode | None = Field(default=None, description="Supported stream mode(s)")
|
|
stream_subgraphs: bool = Field(default=False, description="Include subgraph events")
|
|
stream_resumable: Literal[False] | None = Field(default=None, description="Compatibility placeholder; only the SDK's non-resumable default (null/false) is accepted")
|
|
on_disconnect: Literal["cancel", "continue"] = Field(default="cancel", description="Behaviour on SSE disconnect")
|
|
on_completion: None = Field(default=None, description="Compatibility placeholder; completion behavior is not supported")
|
|
multitask_strategy: Literal["reject", "rollback", "interrupt"] = Field(default="reject", description="Concurrency strategy")
|
|
after_seconds: None = Field(default=None, description="Compatibility placeholder; delayed execution is not supported")
|
|
if_not_exists: Literal["create"] = Field(default="create", description="Compatibility default; missing threads are created")
|
|
feedback_keys: None = Field(default=None, description="Compatibility placeholder; feedback key collection is not supported")
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def lift_context_conversation_references(cls, data: Any) -> Any:
|
|
"""Accept ``context.conversation_references`` as the same explicit grant.
|
|
|
|
LangGraph SDK clients build a fixed run body and drop unknown top-level
|
|
fields, so the web UI can only reach ``conversation_references`` through
|
|
``context``. The key is moved to the top level before field validation,
|
|
so it keeps the same bounds and error locations, and it is removed from
|
|
``context`` so run-context merging never sees it. Sending both is an
|
|
error rather than a silent merge.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return data
|
|
context = data.get("context")
|
|
if not isinstance(context, dict) or "conversation_references" not in context:
|
|
return data
|
|
references = context["conversation_references"]
|
|
top_level = data.get("conversation_references")
|
|
if isinstance(top_level, Iterable) and not isinstance(top_level, _READ_MANY_TIMES):
|
|
# Anything else the field would coerce may be walkable only once
|
|
# (a generator, or an object whose ``__iter__`` hands out one).
|
|
# Materialise it so the probe below and the field validate the same
|
|
# items, instead of the field seeing an exhausted input as [].
|
|
top_level = list(top_level)
|
|
data = {**data, "conversation_references": top_level}
|
|
lifted = {**data, "context": {key: value for key, value in context.items() if key != "conversation_references"}}
|
|
if references is None:
|
|
return lifted
|
|
if top_level is not None:
|
|
try:
|
|
top_level = _REFERENCES_ADAPTER.validate_python(top_level)
|
|
except ValidationError:
|
|
# Let the field report its own error instead of a misleading conflict.
|
|
return data
|
|
if top_level:
|
|
raise PydanticCustomError("conversation_references_conflict", "Pass conversation_references at the top level or in context, not both")
|
|
lifted["conversation_references"] = references
|
|
return lifted
|
|
|
|
@model_validator(mode="after")
|
|
def validate_configurable_thread_id(self) -> RunCreateRequest:
|
|
"""Validate the stateless-run thread selector inside RunnableConfig."""
|
|
if not isinstance(self.config, dict):
|
|
return self
|
|
configurable = self.config.get("configurable")
|
|
if not isinstance(configurable, dict) or "thread_id" not in configurable:
|
|
return self
|
|
thread_id = configurable["thread_id"]
|
|
if thread_id is not None:
|
|
validate_thread_id(thread_id)
|
|
return self
|
|
|
|
@field_validator(
|
|
"webhook",
|
|
"on_completion",
|
|
"multitask_strategy",
|
|
"after_seconds",
|
|
"if_not_exists",
|
|
"feedback_keys",
|
|
mode="before",
|
|
)
|
|
@classmethod
|
|
def reject_unsupported_run_options(cls, value: Any, info: ValidationInfo) -> Any:
|
|
if info.field_name in {"multitask_strategy", "if_not_exists"} and not isinstance(value, str):
|
|
return value
|
|
|
|
supported_defaults = {
|
|
"webhook": None,
|
|
"on_completion": None,
|
|
"multitask_strategy": {"reject", "rollback", "interrupt"},
|
|
"after_seconds": None,
|
|
"if_not_exists": "create",
|
|
"feedback_keys": None,
|
|
}
|
|
supported = supported_defaults[info.field_name]
|
|
if isinstance(supported, set):
|
|
is_supported = isinstance(value, str) and value in supported
|
|
else:
|
|
is_supported = value == supported
|
|
if not is_supported:
|
|
raise PydanticCustomError(
|
|
"unsupported_run_option",
|
|
"Run option '{option}' is not supported by DeerFlow",
|
|
{"option": info.field_name},
|
|
)
|
|
return value
|
|
|
|
@field_validator("stream_resumable", mode="before")
|
|
@classmethod
|
|
def reject_resumable_streams(cls, value: Any) -> Any:
|
|
# LangGraph SDK clients always send this field (its default is ``False``, which the
|
|
# payload's ``None`` filter keeps). ``False`` asks for the non-resumable stream
|
|
# DeerFlow already serves, so only an explicit ``True`` requests the unsupported feature.
|
|
if value is None or value is False:
|
|
return value
|
|
raise PydanticCustomError(
|
|
"unsupported_run_option",
|
|
"Run option '{option}' is not supported by DeerFlow",
|
|
{"option": "stream_resumable"},
|
|
)
|
|
|
|
@field_validator("stream_mode", mode="before")
|
|
@classmethod
|
|
def reject_unsupported_stream_modes(cls, value: Any) -> Any:
|
|
if value is None:
|
|
return value
|
|
if not isinstance(value, str) and (not isinstance(value, list) or not all(isinstance(mode, str) for mode in value)):
|
|
return value
|
|
try:
|
|
normalize_stream_modes(value)
|
|
except UnsupportedStreamModeError as exc:
|
|
modes = ", ".join(exc.modes)
|
|
raise PydanticCustomError(
|
|
"unsupported_stream_mode",
|
|
"Unsupported stream mode(s): {modes}",
|
|
{"modes": modes},
|
|
) from exc
|
|
return value
|