mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
feat(gateway): accept conversation references in run context and report the capability (#5463)
* 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>
This commit is contained in:
parent
1d8b6ead0b
commit
5b591a9039
@ -1497,7 +1497,9 @@ agent has already read stays in the destination conversation after access
|
|||||||
expires or the source is deleted. A message too long for one read carries a
|
expires or the source is deleted. A message too long for one read carries a
|
||||||
continuation, so the agent can read the rest; it asks for the missing part only
|
continuation, so the agent can read the rest; it asks for the missing part only
|
||||||
if that read is unavailable.
|
if that read is unavailable.
|
||||||
This API-only feature adds no frontend selector or automatic history search. See
|
SDK clients that cannot add top-level request fields may send the same list as
|
||||||
|
`context.conversation_references`, and `GET /api/features` reports whether the
|
||||||
|
tool is enabled. There is no frontend selector or automatic history search. See
|
||||||
[configuration](backend/docs/CONFIGURATION.md#reading-referenced-conversations)
|
[configuration](backend/docs/CONFIGURATION.md#reading-referenced-conversations)
|
||||||
and the [request contract](backend/docs/API.md#referencing-a-previous-conversation).
|
and the [request contract](backend/docs/API.md#referencing-a-previous-conversation).
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,12 @@ derive grants from message contents or checkpoints. The callback travels through
|
|||||||
terminal cleanup. `conversation_reader.py` shares the existing HTTP transcript
|
terminal cleanup. `conversation_reader.py` shares the existing HTTP transcript
|
||||||
visibility/pagination logic without a Request dependency; ownership remains a
|
visibility/pagination logic without a Request dependency; ownership remains a
|
||||||
caller responsibility. The tool additionally excludes non-text/internal content.
|
caller responsibility. The tool additionally excludes non-text/internal content.
|
||||||
|
`RunCreateRequest` also accepts the list as `context.conversation_references`
|
||||||
|
(LangGraph SDK clients cannot send top-level extras): a before-validator lifts
|
||||||
|
it into the top-level field, so bounds and error locations are shared, and drops
|
||||||
|
it from `context`, so `merge_run_context_overrides` never sees it; sending both
|
||||||
|
is a 422. `conversation_references_enabled()` is the one "tool is configured"
|
||||||
|
predicate, used by admission and by `/api/features`.
|
||||||
|
|
||||||
FastAPI listens on port 8001; health: `GET /health` (liveness) and `GET /health/ready` (readiness; concurrently probes the ORM engine behind `database:` plus the effective LangGraph checkpointer/Store backend - the legacy `checkpointer:` section, otherwise derived from `database:`, resolved from the startup config snapshot recorded on `app.state` - beneath a single bounded deadline, with connection-opening probes serialized behind a strict per-process gate, 503 while either is unreachable or the startup backend cannot be resolved, `not_configured` for process-local backends such as `backend=memory`). Set `GATEWAY_ENABLE_DOCS=false` to disable the default `/docs`, `/redoc`, and `/openapi.json` endpoints.
|
FastAPI listens on port 8001; health: `GET /health` (liveness) and `GET /health/ready` (readiness; concurrently probes the ORM engine behind `database:` plus the effective LangGraph checkpointer/Store backend - the legacy `checkpointer:` section, otherwise derived from `database:`, resolved from the startup config snapshot recorded on `app.state` - beneath a single bounded deadline, with connection-opening probes serialized behind a strict per-process gate, 503 while either is unreachable or the startup backend cannot be resolved, `not_configured` for process-local backends such as `backend=memory`). Set `GATEWAY_ENABLE_DOCS=false` to disable the default `/docs`, `/redoc`, and `/openapi.json` endpoints.
|
||||||
|
|
||||||
@ -65,7 +71,7 @@ owner-scoped assistant version selection remains enabled.
|
|||||||
| Router | Endpoints |
|
| Router | Endpoints |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
|
||||||
| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, and separate batch repository/worker states so history stays readable without a worker |
|
| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, separate batch repository/worker states so history stays readable without a worker, and `conversation_references` (whether `read_conversation` is configured, plus the per-run reference cap) |
|
||||||
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
|
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
|
||||||
| **MCP** (`/api/mcp`) | GET /config - raw/masked; PUT /config - bulk; PATCH /config - toggle; POST /config/servers - add; PUT /config/server - replace; DELETE /config/servers/{server_name:path} - bodyless. Validate expanded, save raw; reload/reset; invalid -> 400. |
|
| **MCP** (`/api/mcp`) | GET /config - raw/masked; PUT /config - bulk; PATCH /config - toggle; POST /config/servers - add; PUT /config/server - replace; DELETE /config/servers/{server_name:path} - bodyless. Validate expanded, save raw; reload/reset; invalid -> 400. |
|
||||||
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
|
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
|
||||||
|
|||||||
@ -114,6 +114,15 @@ def _fit_text(item: dict, room: int) -> str:
|
|||||||
return text[:low]
|
return text[:low]
|
||||||
|
|
||||||
|
|
||||||
|
def conversation_references_enabled(app_config: AppConfig) -> bool:
|
||||||
|
"""Whether runs may carry references: the opt-in tool is in the configured tool list.
|
||||||
|
|
||||||
|
Shared by run admission and ``/api/features`` so the UI gate and the
|
||||||
|
server check cannot drift.
|
||||||
|
"""
|
||||||
|
return any(tool.use == CONVERSATION_TOOL_USE for tool in app_config.tools)
|
||||||
|
|
||||||
|
|
||||||
def _is_int(value: object) -> bool:
|
def _is_int(value: object) -> bool:
|
||||||
return isinstance(value, int) and not isinstance(value, bool)
|
return isinstance(value, int) and not isinstance(value, bool)
|
||||||
|
|
||||||
@ -143,7 +152,7 @@ def prepare_conversation_reader(
|
|||||||
"""
|
"""
|
||||||
if not references:
|
if not references:
|
||||||
return None
|
return None
|
||||||
if not any(tool.use == CONVERSATION_TOOL_USE for tool in app_config.tools):
|
if not conversation_references_enabled(app_config):
|
||||||
raise HTTPException(status_code=400, detail="read_conversation is not enabled")
|
raise HTTPException(status_code=400, detail="read_conversation is not enabled")
|
||||||
auth = getattr(request.state, "auth", None)
|
auth = getattr(request.state, "auth", None)
|
||||||
if auth is None or not auth.is_authenticated or not auth.has_permission("runs", "read") or not user_id:
|
if auth is None or not auth.is_authenticated or not auth.has_permission("runs", "read") or not user_id:
|
||||||
|
|||||||
@ -11,7 +11,9 @@ from fastapi import APIRouter, Depends, Request
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.browser_capability import browser_capability
|
from app.gateway.browser_capability import browser_capability
|
||||||
|
from app.gateway.conversation_access import conversation_references_enabled
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
|
from app.gateway.run_models import MAX_CONVERSATION_REFERENCES
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.subagents.capacity import configured_subagent_max_running
|
from deerflow.subagents.capacity import configured_subagent_max_running
|
||||||
|
|
||||||
@ -45,6 +47,13 @@ class SubagentBatchesFeature(BaseModel):
|
|||||||
max_running: int = Field(..., description="Native subagent execution slots in this Gateway process")
|
max_running: int = Field(..., description="Native subagent execution slots in this Gateway process")
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationReferencesFeature(BaseModel):
|
||||||
|
"""Availability of explicit conversation references on run requests."""
|
||||||
|
|
||||||
|
enabled: bool = Field(..., description="Whether the opt-in read_conversation tool is configured, so run requests may carry conversation_references")
|
||||||
|
max_references: int = Field(..., description="Maximum conversation references accepted on one run request")
|
||||||
|
|
||||||
|
|
||||||
class FeaturesResponse(BaseModel):
|
class FeaturesResponse(BaseModel):
|
||||||
"""Frontend-facing feature availability flags."""
|
"""Frontend-facing feature availability flags."""
|
||||||
|
|
||||||
@ -52,6 +61,7 @@ class FeaturesResponse(BaseModel):
|
|||||||
browser_control: BrowserControlFeature
|
browser_control: BrowserControlFeature
|
||||||
mcp_tasks: McpTasksFeature
|
mcp_tasks: McpTasksFeature
|
||||||
subagent_batches: SubagentBatchesFeature
|
subagent_batches: SubagentBatchesFeature
|
||||||
|
conversation_references: ConversationReferencesFeature
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@ -80,4 +90,11 @@ async def list_features(request: Request, config: AppConfig = Depends(get_config
|
|||||||
worker_running=subagent_batch_worker_running,
|
worker_running=subagent_batch_worker_running,
|
||||||
max_running=configured_subagent_max_running(),
|
max_running=configured_subagent_max_running(),
|
||||||
),
|
),
|
||||||
|
# Same predicate as run admission (``prepare_conversation_reader``), read
|
||||||
|
# through ``get_config`` so enabling the tool in config.yaml shows up
|
||||||
|
# without a restart. A UI with no entry point still needs no change here.
|
||||||
|
conversation_references=ConversationReferencesFeature(
|
||||||
|
enabled=conversation_references_enabled(config),
|
||||||
|
max_references=MAX_CONVERSATION_REFERENCES,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,14 +2,29 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, ValidationInfo, field_validator, model_validator
|
||||||
from pydantic_core import PydanticCustomError
|
from pydantic_core import PydanticCustomError
|
||||||
|
|
||||||
from deerflow.runtime.stream_modes import RunStreamMode, UnsupportedStreamModeError, normalize_stream_modes
|
from deerflow.runtime.stream_modes import RunStreamMode, UnsupportedStreamModeError, normalize_stream_modes
|
||||||
from deerflow.utils.thread_id import validate_thread_id
|
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):
|
class RunCreateRequest(BaseModel):
|
||||||
"""Validated run request used by both HTTP and internal launch paths."""
|
"""Validated run request used by both HTTP and internal launch paths."""
|
||||||
@ -22,8 +37,10 @@ class RunCreateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] | None = Field(default=None, description="Run metadata")
|
metadata: dict[str, Any] | None = Field(default=None, description="Run metadata")
|
||||||
config: dict[str, Any] | None = Field(default=None, description="RunnableConfig overrides")
|
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.)")
|
context: dict[str, Any] | None = Field(default=None, description="DeerFlow context overrides (model_name, thinking_enabled, etc.)")
|
||||||
conversation_references: list[Annotated[str, Field(strict=True, min_length=1, max_length=2048)]] = Field(
|
conversation_references: list[ConversationReference] = Field(
|
||||||
default_factory=list, max_length=3, description="Explicit thread IDs or same-origin chat URLs readable only during this run (opt-in read_conversation tool)"
|
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")
|
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_id: str | None = Field(default=None, description="Resume from checkpoint")
|
||||||
@ -40,6 +57,46 @@ class RunCreateRequest(BaseModel):
|
|||||||
if_not_exists: Literal["create"] = Field(default="create", description="Compatibility default; missing threads are created")
|
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")
|
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")
|
@model_validator(mode="after")
|
||||||
def validate_configurable_thread_id(self) -> RunCreateRequest:
|
def validate_configurable_thread_id(self) -> RunCreateRequest:
|
||||||
"""Validate the stateless-run thread selector inside RunnableConfig."""
|
"""Validate the stateless-run thread selector inside RunnableConfig."""
|
||||||
|
|||||||
@ -344,6 +344,25 @@ links in pasted documents, tool results, or previous messages grant no access.
|
|||||||
The server supplies source IDs to the model as background user-role data and
|
The server supplies source IDs to the model as background user-role data and
|
||||||
binds the reader to this run's references and authenticated identity.
|
binds the reader to this run's references and authenticated identity.
|
||||||
|
|
||||||
|
Clients that cannot add top-level fields to a run request (the LangGraph JS SDK
|
||||||
|
builds a fixed body and drops unknown keys) may send the same list as
|
||||||
|
`context.conversation_references`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": {"messages": [{"role": "user", "content": "Use the requirements agreed in the referenced conversation."}]},
|
||||||
|
"context": {"conversation_references": ["https://deerflow.example/workspace/chats/source-thread"]}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Gateway lifts the key out of `context` before the run context is assembled,
|
||||||
|
so it has the same bounds and error locations as the top-level field, is
|
||||||
|
recorded on the run in the same way, and never reaches the merged run context
|
||||||
|
or the checkpointed `configurable`. Sending the top-level field and the context
|
||||||
|
key together returns 422. `GET /api/features` reports
|
||||||
|
`conversation_references.enabled` (the tool is configured) and `max_references`,
|
||||||
|
so a client can hide its entry point on deployments without the tool.
|
||||||
|
|
||||||
The request requires `runs:read` as well as the normal run-creation permission.
|
The request requires `runs:read` as well as the normal run-creation permission.
|
||||||
The tool rechecks source ownership on each read; foreign, deleted and unowned
|
The tool rechecks source ownership on each read; foreign, deleted and unowned
|
||||||
legacy threads are unavailable. `read_conversation(thread_id, cursor?, limit?)`
|
legacy threads are unavailable. `read_conversation(thread_id, cursor?, limit?)`
|
||||||
|
|||||||
@ -571,6 +571,10 @@ Custom agents must also permit the `conversation` tool group where they restrict
|
|||||||
groups. References are limited to owned threads and the current run; they do not
|
groups. References are limited to owned threads and the current run; they do not
|
||||||
enable history discovery, memory extraction or cross-user access. See the
|
enable history discovery, memory extraction or cross-user access. See the
|
||||||
[request contract and limits](API.md#referencing-a-previous-conversation).
|
[request contract and limits](API.md#referencing-a-previous-conversation).
|
||||||
|
Once the tool is listed, `GET /api/features` reports
|
||||||
|
`conversation_references.enabled: true` and the per-run cap, so a client can
|
||||||
|
show an entry point only where the tool exists; SDK clients that cannot add
|
||||||
|
top-level request fields pass the list as `context.conversation_references`.
|
||||||
|
|
||||||
Reader pages are sized to stay within the `tool_output` budget for
|
Reader pages are sized to stay within the `tool_output` budget for
|
||||||
`read_conversation` (12,000 serialized characters by default), so they are not
|
`read_conversation` (12,000 serialized characters by default), so they are not
|
||||||
|
|||||||
230
backend/tests/test_conversation_references_context_channel.py
Normal file
230
backend/tests/test_conversation_references_context_channel.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
"""``context.conversation_references`` is the same explicit grant as the top-level field.
|
||||||
|
|
||||||
|
LangGraph SDK clients build a fixed run body and drop unknown top-level fields,
|
||||||
|
so the web UI cannot send ``conversation_references`` there. The ``context``
|
||||||
|
object does reach the Gateway. The request model lifts the key out of it before
|
||||||
|
any run context is assembled, so it is consumed at admission and never forwarded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections import UserList, deque
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.gateway.authz import AuthContext
|
||||||
|
from app.gateway.run_models import MAX_CONVERSATION_REFERENCES, RunCreateRequest
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_references_are_lifted_into_the_request_and_removed_from_context():
|
||||||
|
body = RunCreateRequest(context={"conversation_references": ["source"], "thinking_enabled": True})
|
||||||
|
assert body.conversation_references == ["source"]
|
||||||
|
assert body.context == {"thinking_enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [None, []])
|
||||||
|
def test_empty_context_references_grant_nothing_and_leave_no_key(value):
|
||||||
|
body = RunCreateRequest(context={"conversation_references": value, "mode": "flash"})
|
||||||
|
assert body.conversation_references == []
|
||||||
|
assert body.context == {"mode": "flash"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"invalid",
|
||||||
|
["source", [""], [1], ["x" * 2049], ["s"] * (MAX_CONVERSATION_REFERENCES + 1), {"thread": "source"}],
|
||||||
|
)
|
||||||
|
def test_context_references_keep_the_top_level_bounds(invalid):
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(context={"conversation_references": invalid})
|
||||||
|
assert exc.value.errors()
|
||||||
|
assert all(error["loc"][0] == "conversation_references" for error in exc.value.errors())
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_level_and_context_references_together_are_rejected():
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(conversation_references=["source"], context={"conversation_references": ["source"]})
|
||||||
|
assert [error["type"] for error in exc.value.errors()] == ["conversation_references_conflict"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_malformed_top_level_value_reports_its_type_error_not_the_conflict():
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(conversation_references="source", context={"conversation_references": ["source"]})
|
||||||
|
assert [(error["type"], error["loc"]) for error in exc.value.errors()] == [("list_type", ("conversation_references",))]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"top_level",
|
||||||
|
[
|
||||||
|
("source",),
|
||||||
|
{"source"},
|
||||||
|
frozenset({"source"}),
|
||||||
|
deque(["source"]),
|
||||||
|
UserList(["source"]),
|
||||||
|
dict.fromkeys(["source"]).keys(),
|
||||||
|
(item for item in ("source",)),
|
||||||
|
],
|
||||||
|
ids=["tuple", "set", "frozenset", "deque", "UserList", "dict_keys", "generator"],
|
||||||
|
)
|
||||||
|
def test_everything_the_field_would_coerce_also_reports_the_conflict(top_level):
|
||||||
|
# Pydantic's lax mode coerces many iterables into the list field, so a
|
||||||
|
# direct Python caller must not slip both grants past the conflict check.
|
||||||
|
# The guard asks pydantic itself instead of enumerating types.
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(conversation_references=top_level, context={"conversation_references": ["source"]})
|
||||||
|
assert [error["type"] for error in exc.value.errors()] == ["conversation_references_conflict"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("top_level", [0, False, 1.5, {"thread": "source"}, b"source"], ids=["zero", "false", "float", "dict", "bytes"])
|
||||||
|
def test_values_the_field_rejects_still_report_their_own_type_error(top_level):
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(conversation_references=top_level, context={"conversation_references": ["source"]})
|
||||||
|
assert [(error["type"], error["loc"]) for error in exc.value.errors()] == [("list_type", ("conversation_references",))]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"top_level",
|
||||||
|
[[""], [1], range(3), dict.fromkeys(["source"]).values(), dict.fromkeys(["source"]).items()],
|
||||||
|
ids=["empty_item", "int_item", "range", "dict_values", "dict_items"],
|
||||||
|
)
|
||||||
|
def test_invalid_items_at_the_top_level_report_the_item_error_not_the_conflict(top_level):
|
||||||
|
# The guard probes with the field's own annotation, so whatever the field
|
||||||
|
# coerces as a container but rejects per item (a range, dict views, a bad
|
||||||
|
# string) is reported at the item's index rather than as a conflict.
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(conversation_references=top_level, context={"conversation_references": ["source"]})
|
||||||
|
errors = exc.value.errors()
|
||||||
|
assert errors
|
||||||
|
assert all(error["loc"][0] == "conversation_references" and isinstance(error["loc"][1], int) for error in errors)
|
||||||
|
assert not any(error["type"] == "conversation_references_conflict" for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_one_shot_iterator_with_a_bad_item_still_reports_the_item_error():
|
||||||
|
# The probe must not consume a generator and leave the field an exhausted
|
||||||
|
# one that coerces to [] and validates silently with the key left in context.
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(conversation_references=(item for item in ["", "source"]), context={"conversation_references": ["source"]})
|
||||||
|
errors = exc.value.errors()
|
||||||
|
assert [error["loc"] for error in errors] == [("conversation_references", 0)]
|
||||||
|
assert not any(error["type"] == "conversation_references_conflict" for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
class _OneShotIterable:
|
||||||
|
"""An iterable that is not an ``Iterator`` but can be walked only once."""
|
||||||
|
|
||||||
|
def __init__(self, items):
|
||||||
|
self._items = list(items)
|
||||||
|
self._spent = False
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
if self._spent:
|
||||||
|
return iter(())
|
||||||
|
self._spent = True
|
||||||
|
return iter(self._items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_one_shot_iterable_that_is_not_an_iterator_is_also_read_once():
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(conversation_references=_OneShotIterable(["", "source"]), context={"conversation_references": ["source"]})
|
||||||
|
errors = exc.value.errors()
|
||||||
|
assert [error["loc"] for error in errors] == [("conversation_references", 0)]
|
||||||
|
assert not any(error["type"] == "conversation_references_conflict" for error in errors)
|
||||||
|
body = RunCreateRequest(conversation_references=_OneShotIterable(["source"]), context={"thinking_enabled": True})
|
||||||
|
assert body.conversation_references == ["source"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_one_shot_iterator_of_valid_items_is_read_once_and_kept():
|
||||||
|
body = RunCreateRequest(conversation_references=(item for item in ["source"]), context={"thinking_enabled": True})
|
||||||
|
assert body.conversation_references == ["source"]
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
RunCreateRequest(conversation_references=(item for item in ["source"]), context={"conversation_references": ["other"]})
|
||||||
|
assert [error["type"] for error in exc.value.errors()] == ["conversation_references_conflict"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_top_level_list_does_not_conflict_with_context():
|
||||||
|
body = RunCreateRequest(conversation_references=[], context={"conversation_references": ["source"]})
|
||||||
|
assert body.conversation_references == ["source"]
|
||||||
|
assert body.context == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_references_matches_the_field_bound():
|
||||||
|
assert RunCreateRequest(conversation_references=["s"] * MAX_CONVERSATION_REFERENCES).conversation_references == ["s"] * MAX_CONVERSATION_REFERENCES
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
RunCreateRequest(conversation_references=["s"] * (MAX_CONVERSATION_REFERENCES + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_run_grants_through_context_without_forwarding_the_key(monkeypatch):
|
||||||
|
from test_gateway_services import _make_start_run_persistence_context
|
||||||
|
|
||||||
|
from app.gateway import services
|
||||||
|
from deerflow.config.app_config import reset_app_config, set_app_config
|
||||||
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||||
|
|
||||||
|
async def exercise():
|
||||||
|
request, _, threads = _make_start_run_persistence_context()
|
||||||
|
user = SimpleNamespace(id="alice", system_role="admin", role="admin")
|
||||||
|
request.state.user = user
|
||||||
|
request.state.auth = AuthContext(user, ["runs:create", "runs:read"])
|
||||||
|
request.state.auth_source = "session"
|
||||||
|
request.url = "https://deerflow.example/api/threads/current/runs"
|
||||||
|
await threads.create("source", user_id="alice")
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
async def fake_run_agent(*args, **kwargs):
|
||||||
|
captured.append(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(services, "run_agent", fake_run_agent)
|
||||||
|
monkeypatch.setattr(services, "resolve_agent_factory", lambda *_: object())
|
||||||
|
|
||||||
|
body = RunCreateRequest(
|
||||||
|
input={"messages": [{"role": "user", "content": "Read the reference"}]},
|
||||||
|
context={"conversation_references": ["source"], "thinking_enabled": True},
|
||||||
|
)
|
||||||
|
record = await services.start_run(body, "current", request)
|
||||||
|
await record.task
|
||||||
|
assert callable(captured[0]["ctx"].conversation_reader)
|
||||||
|
assert any(isinstance(m, HumanMessage) and "source" in str(m.content) for m in captured[0]["graph_input"]["messages"])
|
||||||
|
# The run record keeps the references exactly like the top-level field.
|
||||||
|
assert record.kwargs["conversation_references"] == ["source"]
|
||||||
|
assert "__conversation_reader" not in json.dumps(record.kwargs)
|
||||||
|
# Other context keys still flow; the references never reach the run context or the checkpointed configurable.
|
||||||
|
config = captured[0]["config"]
|
||||||
|
assert config["context"]["thinking_enabled"] is True
|
||||||
|
assert "conversation_references" not in config["context"]
|
||||||
|
assert "conversation_references" not in config["configurable"]
|
||||||
|
|
||||||
|
# Copies smuggled through the free-form RunnableConfig grant nothing.
|
||||||
|
smuggled = await services.start_run(
|
||||||
|
RunCreateRequest(
|
||||||
|
input={"messages": [{"role": "user", "content": "again"}]},
|
||||||
|
config={"configurable": {"conversation_references": ["source"]}, "context": {"conversation_references": ["source"]}},
|
||||||
|
),
|
||||||
|
"smuggled",
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
await smuggled.task
|
||||||
|
assert captured[1]["ctx"].conversation_reader is None
|
||||||
|
assert "conversation_references" not in (smuggled.kwargs or {})
|
||||||
|
|
||||||
|
# Idempotent replay compares the lifted references like the top-level field.
|
||||||
|
keyed = await services.start_run(body, "idempotent", request, idempotency_key="ctx-key")
|
||||||
|
await keyed.task
|
||||||
|
assert await services.start_run(body, "idempotent", request, idempotency_key="ctx-key") is keyed
|
||||||
|
changed = RunCreateRequest(input=body.input, context={"conversation_references": ["other"], "thinking_enabled": True})
|
||||||
|
with pytest.raises(HTTPException) as conflict:
|
||||||
|
await services.start_run(changed, "idempotent", request, idempotency_key="ctx-key")
|
||||||
|
assert conflict.value.status_code == 409
|
||||||
|
|
||||||
|
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "tools": [{"name": "read_conversation", "group": "conversation", "use": "deerflow.tools.conversation:read_conversation"}]}))
|
||||||
|
user_token = set_current_user(SimpleNamespace(id="alice"))
|
||||||
|
try:
|
||||||
|
asyncio.run(exercise())
|
||||||
|
finally:
|
||||||
|
reset_current_user(user_token)
|
||||||
|
reset_app_config()
|
||||||
@ -16,6 +16,7 @@ def _app_with_config(
|
|||||||
mcp_tasks_available: bool = False,
|
mcp_tasks_available: bool = False,
|
||||||
subagent_batches_available: bool = False,
|
subagent_batches_available: bool = False,
|
||||||
subagent_batch_repo_available: bool | None = None,
|
subagent_batch_repo_available: bool | None = None,
|
||||||
|
conversation_references_enabled: bool = False,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.state.mcp_tasks_available = mcp_tasks_available
|
app.state.mcp_tasks_available = mcp_tasks_available
|
||||||
@ -24,13 +25,11 @@ def _app_with_config(
|
|||||||
subagent_batch_repo_available = subagent_batches_available
|
subagent_batch_repo_available = subagent_batches_available
|
||||||
app.state.subagent_batch_repo = object() if subagent_batch_repo_available else None
|
app.state.subagent_batch_repo = object() if subagent_batch_repo_available else None
|
||||||
app.include_router(features.router)
|
app.include_router(features.router)
|
||||||
tools = (
|
tools = []
|
||||||
[
|
if browser_enabled:
|
||||||
SimpleNamespace(name="browser_navigate", model_extra=browser_extra or {}),
|
tools.append(SimpleNamespace(name="browser_navigate", use="deerflow.community.browser:browser_navigate_tool", model_extra=browser_extra or {}))
|
||||||
]
|
if conversation_references_enabled:
|
||||||
if browser_enabled
|
tools.append(SimpleNamespace(name="read_conversation", use="deerflow.tools.conversation:read_conversation", model_extra={}))
|
||||||
else []
|
|
||||||
)
|
|
||||||
fake_config = SimpleNamespace(
|
fake_config = SimpleNamespace(
|
||||||
agents_api=SimpleNamespace(enabled=agents_api_enabled),
|
agents_api=SimpleNamespace(enabled=agents_api_enabled),
|
||||||
tools=tools,
|
tools=tools,
|
||||||
@ -54,6 +53,7 @@ def test_features_reports_agents_api_enabled() -> None:
|
|||||||
"worker_running": False,
|
"worker_running": False,
|
||||||
"max_running": 3,
|
"max_running": 3,
|
||||||
},
|
},
|
||||||
|
"conversation_references": {"enabled": False, "max_references": 3},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -71,9 +71,17 @@ def test_features_reports_agents_api_disabled() -> None:
|
|||||||
"worker_running": False,
|
"worker_running": False,
|
||||||
"max_running": 3,
|
"max_running": 3,
|
||||||
},
|
},
|
||||||
|
"conversation_references": {"enabled": False, "max_references": 3},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_reports_conversation_references_when_the_tool_is_configured() -> None:
|
||||||
|
with TestClient(_app_with_config(agents_api_enabled=True, conversation_references_enabled=True)) as client:
|
||||||
|
response = client.get("/api/features")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["conversation_references"] == {"enabled": True, "max_references": 3}
|
||||||
|
|
||||||
|
|
||||||
def test_features_reports_mcp_tasks_startup_capability() -> None:
|
def test_features_reports_mcp_tasks_startup_capability() -> None:
|
||||||
with TestClient(_app_with_config(agents_api_enabled=True, mcp_tasks_available=True)) as client:
|
with TestClient(_app_with_config(agents_api_enabled=True, mcp_tasks_available=True)) as client:
|
||||||
response = client.get("/api/features")
|
response = client.get("/api/features")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user