diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index 217605685..d29786edd 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -38,6 +38,7 @@ class RunCreateRequest(BaseModel): 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.)") webhook: str | None = Field(default=None, description="Completion callback URL") checkpoint_id: str | None = Field(default=None, description="Resume from checkpoint") checkpoint: dict[str, Any] | None = Field(default=None, description="Full checkpoint object") diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index ea9e8662b..dffedce1c 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -271,6 +271,27 @@ async def start_run( agent_factory = resolve_agent_factory(body.assistant_id) graph_input = normalize_input(body.input) config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id) + + # Merge DeerFlow-specific context overrides into configurable. + # The ``context`` field is a custom extension for the langgraph-compat layer + # that carries agent configuration (model_name, thinking_enabled, etc.). + # Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored. + context = getattr(body, "context", None) + if context: + _CONTEXT_CONFIGURABLE_KEYS = { + "model_name", + "mode", + "thinking_enabled", + "reasoning_effort", + "is_plan_mode", + "subagent_enabled", + "max_concurrent_subagents", + } + configurable = config.setdefault("configurable", {}) + for key in _CONTEXT_CONFIGURABLE_KEYS: + if key in context: + configurable.setdefault(key, context[key]) + stream_modes = normalize_stream_modes(body.stream_mode) task = asyncio.create_task( diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 3921a2b34..6eab20a9f 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -160,3 +160,127 @@ def test_resolve_agent_factory_returns_make_lead_agent(): assert resolve_agent_factory("lead_agent") is make_lead_agent assert resolve_agent_factory("finalis") is make_lead_agent assert resolve_agent_factory("custom-agent-123") is make_lead_agent + + +# --------------------------------------------------------------------------- +# Regression tests for issue #1699: +# context field in langgraph-compat requests not merged into configurable +# --------------------------------------------------------------------------- + + +def test_run_create_request_accepts_context(): + """RunCreateRequest must accept the ``context`` field without dropping it.""" + from app.gateway.routers.thread_runs import RunCreateRequest + + body = RunCreateRequest( + input={"messages": [{"role": "user", "content": "hi"}]}, + context={ + "model_name": "deepseek-v3", + "thinking_enabled": True, + "is_plan_mode": True, + "subagent_enabled": True, + "thread_id": "some-thread-id", + }, + ) + assert body.context is not None + assert body.context["model_name"] == "deepseek-v3" + assert body.context["is_plan_mode"] is True + assert body.context["subagent_enabled"] is True + + +def test_run_create_request_context_defaults_to_none(): + """RunCreateRequest without context should default to None (backward compat).""" + from app.gateway.routers.thread_runs import RunCreateRequest + + body = RunCreateRequest(input=None) + assert body.context is None + + +def test_context_merges_into_configurable(): + """Context values must be merged into config['configurable'] by start_run. + + Since start_run is async and requires many dependencies, we test the + merging logic directly by simulating what start_run does. + """ + from app.gateway.services import build_run_config + + # Simulate the context merging logic from start_run + config = build_run_config("thread-1", None, None) + + context = { + "model_name": "deepseek-v3", + "mode": "ultra", + "reasoning_effort": "high", + "thinking_enabled": True, + "is_plan_mode": True, + "subagent_enabled": True, + "max_concurrent_subagents": 5, + "thread_id": "should-be-ignored", + } + + _CONTEXT_CONFIGURABLE_KEYS = { + "model_name", + "mode", + "thinking_enabled", + "reasoning_effort", + "is_plan_mode", + "subagent_enabled", + "max_concurrent_subagents", + } + configurable = config.setdefault("configurable", {}) + for key in _CONTEXT_CONFIGURABLE_KEYS: + if key in context: + configurable.setdefault(key, context[key]) + + assert config["configurable"]["model_name"] == "deepseek-v3" + assert config["configurable"]["thinking_enabled"] is True + assert config["configurable"]["is_plan_mode"] is True + assert config["configurable"]["subagent_enabled"] is True + assert config["configurable"]["max_concurrent_subagents"] == 5 + assert config["configurable"]["reasoning_effort"] == "high" + assert config["configurable"]["mode"] == "ultra" + # thread_id from context should NOT override the one from build_run_config + assert config["configurable"]["thread_id"] == "thread-1" + # Non-allowlisted keys should not appear + assert "thread_id" not in {k for k in context if k in _CONTEXT_CONFIGURABLE_KEYS} + + +def test_context_does_not_override_existing_configurable(): + """Values already in config.configurable must NOT be overridden by context. + + This ensures that explicit configurable values from the ``config`` field + take precedence over the ``context`` field. + """ + from app.gateway.services import build_run_config + + config = build_run_config( + "thread-1", + {"configurable": {"model_name": "gpt-4", "is_plan_mode": False}}, + None, + ) + + context = { + "model_name": "deepseek-v3", + "is_plan_mode": True, + "subagent_enabled": True, + } + + _CONTEXT_CONFIGURABLE_KEYS = { + "model_name", + "mode", + "thinking_enabled", + "reasoning_effort", + "is_plan_mode", + "subagent_enabled", + "max_concurrent_subagents", + } + configurable = config.setdefault("configurable", {}) + for key in _CONTEXT_CONFIGURABLE_KEYS: + if key in context: + configurable.setdefault(key, context[key]) + + # Existing values must NOT be overridden + assert config["configurable"]["model_name"] == "gpt-4" + assert config["configurable"]["is_plan_mode"] is False + # New values should be added + assert config["configurable"]["subagent_enabled"] is True