diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index 294fa9799d..10479a00d6 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -39,7 +39,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.)") + context: dict[str, Any] | None = Field(default=None, description="DeerFlow context overrides (model_name, thinking_enabled, etc.). May include client capabilities under context.client.") 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 4713d303ea..63f9b20a95 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -32,6 +32,7 @@ UnsupportedStrategyError, run_agent, ) +from deerflow.runtime.client_context import sanitize_client_context from deerflow.runtime.runs.naming import resolve_root_run_name logger = logging.getLogger(__name__) @@ -125,9 +126,22 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An """Merge whitelisted keys from ``body.context`` into both ``config['configurable']`` and ``config['context']`` so they are visible to legacy configurable readers and to LangGraph ``ToolRuntime.context`` consumers (e.g. the ``setup_agent`` tool — - see issue #2677).""" + see issue #2677). + + Client capability context is runtime-only: it is sanitized and copied into + ``config['context']['client']`` but never into ``configurable``. + """ + existing_runtime_context = config.get("context") + if isinstance(existing_runtime_context, dict) and "client" in existing_runtime_context: + existing_client_context = sanitize_client_context(existing_runtime_context.get("client")) + if existing_client_context is None: + existing_runtime_context.pop("client", None) + else: + existing_runtime_context["client"] = existing_client_context + if not context: return + configurable = config.setdefault("configurable", {}) runtime_context = config.setdefault("context", {}) for key in _CONTEXT_CONFIGURABLE_KEYS: @@ -137,6 +151,10 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An if isinstance(runtime_context, dict): runtime_context.setdefault(key, context[key]) + client_context = sanitize_client_context(context.get("client")) + if client_context is not None and isinstance(runtime_context, dict): + runtime_context.setdefault("client", client_context) + def inject_authenticated_user_context(config: dict[str, Any], request: Request) -> None: """Stamp the authenticated user into the run context for background tools. diff --git a/backend/docs/API.md b/backend/docs/API.md index 762a135c44..94cf999830 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -115,6 +115,42 @@ nested subagent graphs. - `thinking_enabled` (boolean): Enable extended thinking for supported models - `is_plan_mode` (boolean): Enable TodoList middleware for task tracking +**Client Context:** + +Clients may describe their output capabilities under top-level `context.client`. +The gateway keeps only the prompt-relevant, non-sensitive fields (`name`, +`capabilities`, and `preferences`) and places the sanitized value in the run +runtime context. The agent may also receive a compact hidden +`` reminder so skills can choose an output shape that fits the +calling client. + +```json +{ + "input": { + "messages": [ + { + "role": "user", + "content": "Analyze this dataset" + } + ] + }, + "context": { + "client": { + "name": "custom-analytics-frontend", + "capabilities": { + "artifacts": true, + "csv_download": true, + "charts": true + }, + "preferences": { + "csv": "present", + "chart": "present" + } + } + } +} +``` + **Response:** Server-Sent Events (SSE) stream ``` diff --git a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py index 95b9f9a082..426a75fe3b 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py @@ -1,10 +1,10 @@ -"""Middleware to inject dynamic context (memory, current date) as a system-reminder. +"""Middleware to inject dynamic context (memory, client context, current date) as a system-reminder. The system prompt is kept fully static for maximum prefix-cache reuse across users and sessions. The current date is always injected. Per-user memory is also injected -when ``memory.injection_enabled`` is True in the app config. Both are delivered once -per conversation as a dedicated HumanMessage inserted before the -first user message (frozen-snapshot pattern). +when ``memory.injection_enabled`` is True in the app config. Sanitized client +capabilities are injected when present in ``runtime.context["client"]``. These are +delivered as dedicated HumanMessages inserted before user messages. When a conversation spans midnight the middleware detects the date change and injects a lightweight date-update reminder as a separate HumanMessage before the current turn. @@ -16,14 +16,21 @@ ... + ... + 2026-05-08, Friday Date-update format: + ... + 2026-05-09, Saturday + +If no client context is active during a date update, the ```` +block is omitted. """ from __future__ import annotations @@ -31,19 +38,23 @@ import logging import re import uuid +from collections.abc import Mapping from datetime import datetime -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, Any, override from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import HumanMessage from langgraph.runtime import Runtime +from deerflow.runtime.client_context import render_client_context_for_prompt, render_empty_client_context_for_prompt + if TYPE_CHECKING: from deerflow.config.app_config import AppConfig logger = logging.getLogger(__name__) _DATE_RE = re.compile(r"([^<]+)") +_CLIENT_CONTEXT_RE = re.compile(r".*?", re.DOTALL) _DYNAMIC_CONTEXT_REMINDER_KEY = "dynamic_context_reminder" _SUMMARY_MESSAGE_NAME = "summary" @@ -54,6 +65,12 @@ def _extract_date(content: str) -> str | None: return m.group(1) if m else None +def _extract_client_context(content: str) -> str | None: + """Return the first rendered block found in *content*.""" + m = _CLIENT_CONTEXT_RE.search(content) + return m.group(0).strip() if m else None + + def is_dynamic_context_reminder(message: object) -> bool: """Return whether *message* is a hidden dynamic-context reminder.""" return isinstance(message, HumanMessage) and bool(message.additional_kwargs.get(_DYNAMIC_CONTEXT_REMINDER_KEY)) @@ -69,7 +86,20 @@ def _last_injected_date(messages: list) -> str | None: for msg in reversed(messages): if is_dynamic_context_reminder(msg): content_str = msg.content if isinstance(msg.content, str) else str(msg.content) - return _extract_date(content_str) + date = _extract_date(content_str) + if date: + return date + return None + + +def _last_injected_client_context(messages: list) -> str | None: + """Return the most recently injected client-context block, if any.""" + for msg in reversed(messages): + if is_dynamic_context_reminder(msg): + content_str = msg.content if isinstance(msg.content, str) else str(msg.content) + client_context = _extract_client_context(content_str) + if client_context: + return client_context return None @@ -78,13 +108,26 @@ def _is_user_injection_target(message: object) -> bool: return isinstance(message, HumanMessage) and not is_dynamic_context_reminder(message) and message.name != _SUMMARY_MESSAGE_NAME +def _runtime_context(runtime: Runtime) -> Mapping[str, Any] | None: + context = getattr(runtime, "context", None) + return context if isinstance(context, Mapping) else None + + +def _runtime_client_context(runtime: Runtime) -> str | None: + context = _runtime_context(runtime) + if context is None: + return None + return render_client_context_for_prompt(context.get("client")) + + class DynamicContextMiddleware(AgentMiddleware): - """Inject memory and current date into HumanMessages as a . + """Inject memory, client context, and current date into hidden reminders. First turn ---------- - Prepends a full system-reminder (memory + date) to the first HumanMessage and - persists it (same message ID). The first message is then frozen for the whole + Prepends a full system-reminder (memory + client context + date) to the + first HumanMessage and persists it (same message ID). The first message is + then frozen for the whole session — its content never changes again, so the prefix cache can hit on every subsequent turn. @@ -101,32 +144,38 @@ def __init__(self, agent_name: str | None = None, *, app_config: AppConfig | Non self._agent_name = agent_name self._app_config = app_config - def _build_full_reminder(self) -> str: + def _build_full_reminder(self, runtime: Runtime) -> str: from deerflow.agents.lead_agent.prompt import _get_memory_context # Memory injection is gated by injection_enabled; date is always included. injection_enabled = self._app_config.memory.injection_enabled if self._app_config else True memory_context = _get_memory_context(self._agent_name, app_config=self._app_config) if injection_enabled else "" + client_context = _runtime_client_context(runtime) current_date = datetime.now().strftime("%Y-%m-%d, %A") lines: list[str] = [""] if memory_context: lines.append(memory_context.strip()) lines.append("") # blank line separating memory from date + if client_context: + lines.append(client_context) + lines.append("") lines.append(f"{current_date}") lines.append("") return "\n".join(lines) - def _build_date_update_reminder(self) -> str: + def _build_date_update_reminder(self, client_context: str | None = None) -> str: current_date = datetime.now().strftime("%Y-%m-%d, %A") - return "\n".join( - [ - "", - f"{current_date}", - "", - ] - ) + lines = [""] + if client_context: + lines.extend([client_context, ""]) + lines.append(f"{current_date}") + lines.append("") + return "\n".join(lines) + + def _build_client_context_update_reminder(self, client_context: str) -> str: + return "\n".join(["", client_context, ""]) @staticmethod def _make_reminder_and_user_messages(original: HumanMessage, reminder_content: str) -> tuple[HumanMessage, HumanMessage]: @@ -153,18 +202,23 @@ def _make_reminder_and_user_messages(original: HumanMessage, reminder_content: s ) return reminder_msg, user_msg - def _inject(self, state) -> dict | None: + def _inject(self, state, runtime: Runtime) -> dict | None: messages = list(state.get("messages", [])) if not messages: return None current_date = datetime.now().strftime("%Y-%m-%d, %A") last_date = _last_injected_date(messages) + last_client_context = _last_injected_client_context(messages) + current_client_context = _runtime_client_context(runtime) + if last_client_context is not None and current_client_context is None: + current_client_context = render_empty_client_context_for_prompt() logger.debug( - "DynamicContextMiddleware._inject: msg_count=%d last_date=%r current_date=%r", + "DynamicContextMiddleware._inject: msg_count=%d last_date=%r current_date=%r has_client_context=%s", len(messages), last_date, current_date, + bool(current_client_context), ) if last_date is None: @@ -172,17 +226,26 @@ def _inject(self, state) -> dict | None: first_idx = next((i for i, m in enumerate(messages) if _is_user_injection_target(m)), None) if first_idx is None: return None - full_reminder = self._build_full_reminder() + full_reminder = self._build_full_reminder(runtime) logger.info( - "DynamicContextMiddleware: injecting full reminder (len=%d, has_memory=%s) into first HumanMessage id=%r", + "DynamicContextMiddleware: injecting full reminder (len=%d, has_memory=%s, has_client_context=%s) into first HumanMessage id=%r", len(full_reminder), "" in full_reminder, + "" in full_reminder, messages[first_idx].id, ) reminder_msg, user_msg = self._make_reminder_and_user_messages(messages[first_idx], full_reminder) return {"messages": [reminder_msg, user_msg]} if last_date == current_date: + if current_client_context is not None and current_client_context != last_client_context: + last_human_idx = next((i for i in reversed(range(len(messages))) if _is_user_injection_target(messages[i])), None) + if last_human_idx is None: + return None + reminder_msg, user_msg = self._make_reminder_and_user_messages(messages[last_human_idx], self._build_client_context_update_reminder(current_client_context)) + logger.info("DynamicContextMiddleware: injected client-context update before current turn") + return {"messages": [reminder_msg, user_msg]} + # ── Same day: nothing to do ────────────────────────────────────────── return None @@ -191,14 +254,14 @@ def _inject(self, state) -> dict | None: if last_human_idx is None: return None - reminder_msg, user_msg = self._make_reminder_and_user_messages(messages[last_human_idx], self._build_date_update_reminder()) + reminder_msg, user_msg = self._make_reminder_and_user_messages(messages[last_human_idx], self._build_date_update_reminder(current_client_context)) logger.info("DynamicContextMiddleware: midnight crossing detected — injected date update before current turn") return {"messages": [reminder_msg, user_msg]} @override def before_agent(self, state, runtime: Runtime) -> dict | None: - return self._inject(state) + return self._inject(state, runtime) @override async def abefore_agent(self, state, runtime: Runtime) -> dict | None: - return self._inject(state) + return self._inject(state, runtime) diff --git a/backend/packages/harness/deerflow/runtime/client_context.py b/backend/packages/harness/deerflow/runtime/client_context.py new file mode 100644 index 0000000000..7c0009c3ce --- /dev/null +++ b/backend/packages/harness/deerflow/runtime/client_context.py @@ -0,0 +1,148 @@ +"""Helpers for request-level client context. + +The HTTP API accepts a broad ``context`` object for compatibility with +LangGraph SDK clients. Only a small, explicit subset of client-provided data +should be preserved for runtime use or rendered into model-visible reminders. +""" + +from __future__ import annotations + +import math +import re +from collections.abc import Mapping +from html import escape +from itertools import islice +from typing import Any + +_CLIENT_CONTEXT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$") +_MAX_CLIENT_CONTEXT_ITEMS = 32 +_MAX_CLIENT_NAME_LENGTH = 80 +_MAX_CLIENT_VALUE_LENGTH = 200 + +_PROMPT_EMPTY_CLIENT_CONTEXT = "not provided" + + +def _clean_key(value: Any) -> str | None: + if not isinstance(value, str): + return None + key = value.strip() + if not key or not _CLIENT_CONTEXT_KEY_RE.fullmatch(key): + return None + return key + + +def _clean_text(value: Any, *, max_length: int = _MAX_CLIENT_VALUE_LENGTH) -> str | None: + if not isinstance(value, str): + return None + # Collapse all whitespace so client-provided values cannot introduce + # prompt structure by adding line breaks or tags across lines. + text = " ".join(value.split()) + if not text: + return None + if len(text) > max_length: + text = text[:max_length] + return text + + +def _clean_preference_value(value: Any) -> str | int | float | bool | None: + if isinstance(value, bool): + return value + if isinstance(value, int): + return value + if isinstance(value, float): + return value if math.isfinite(value) else None + if isinstance(value, str): + return _clean_text(value) + return None + + +def sanitize_client_context(raw_client: Any) -> dict[str, Any] | None: + """Return the safe subset of ``context.client`` for runtime use. + + Accepted prompt-visible fields: + - ``name``: short client identifier + - ``capabilities``: mapping of capability name to boolean support flag + - ``preferences``: mapping of preference name to a scalar value + + Unknown keys and nested structures are intentionally dropped. + """ + + if not isinstance(raw_client, Mapping): + return None + + client: dict[str, Any] = {} + + name = _clean_text(raw_client.get("name"), max_length=_MAX_CLIENT_NAME_LENGTH) + if name: + client["name"] = name + + raw_capabilities = raw_client.get("capabilities") + if isinstance(raw_capabilities, Mapping): + capabilities: dict[str, bool] = {} + for raw_key, raw_value in islice(raw_capabilities.items(), _MAX_CLIENT_CONTEXT_ITEMS): + key = _clean_key(raw_key) + if key is None or not isinstance(raw_value, bool): + continue + capabilities[key] = raw_value + if capabilities: + client["capabilities"] = dict(sorted(capabilities.items())) + + raw_preferences = raw_client.get("preferences") + if isinstance(raw_preferences, Mapping): + preferences: dict[str, str | int | float | bool] = {} + for raw_key, raw_value in islice(raw_preferences.items(), _MAX_CLIENT_CONTEXT_ITEMS): + key = _clean_key(raw_key) + value = _clean_preference_value(raw_value) + if key is None or value is None: + continue + preferences[key] = value + if preferences: + client["preferences"] = dict(sorted(preferences.items())) + + return client or None + + +def _format_prompt_value(value: str | int | float | bool) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return escape(value, quote=False) + return str(value) + + +def render_client_context_for_prompt(raw_client: Any) -> str | None: + """Render safe client context as a compact model-visible reminder block.""" + + client = sanitize_client_context(raw_client) + if not client: + return None + + lines = [""] + + name = client.get("name") + if isinstance(name, str): + lines.append(f"name: {escape(name, quote=False)}") + + capabilities = client.get("capabilities") + if isinstance(capabilities, Mapping): + enabled = sorted(key for key, enabled in capabilities.items() if enabled is True) + disabled = sorted(key for key, enabled in capabilities.items() if enabled is False) + if enabled: + lines.append(f"capabilities: {', '.join(enabled)}") + if disabled: + lines.append(f"unsupported_capabilities: {', '.join(disabled)}") + + preferences = client.get("preferences") + if isinstance(preferences, Mapping): + rendered = [f"{key}={_format_prompt_value(value)}" for key, value in preferences.items() if isinstance(value, (str, int, float, bool))] + if rendered: + lines.append(f"preferences: {'; '.join(rendered)}") + + lines.append("") + return "\n".join(lines) + + +def render_empty_client_context_for_prompt() -> str: + """Render an explicit reminder that no client context is active.""" + + return _PROMPT_EMPTY_CLIENT_CONTEXT diff --git a/backend/tests/test_client_context.py b/backend/tests/test_client_context.py new file mode 100644 index 0000000000..251d96b089 --- /dev/null +++ b/backend/tests/test_client_context.py @@ -0,0 +1,84 @@ +from deerflow.runtime.client_context import ( + render_client_context_for_prompt, + sanitize_client_context, +) + + +def test_sanitize_client_context_keeps_only_prompt_relevant_fields(): + client = sanitize_client_context( + { + "name": "custom-analytics-frontend", + "access_token": "secret", + "capabilities": { + "artifacts": True, + "csv_download": True, + "charts": False, + "bad key": True, + "string_bool": "true", + }, + "preferences": { + "csv": "present", + "chart": "present", + "nested": {"drop": True}, + "empty": "", + }, + } + ) + + assert client == { + "name": "custom-analytics-frontend", + "capabilities": { + "artifacts": True, + "charts": False, + "csv_download": True, + }, + "preferences": { + "chart": "present", + "csv": "present", + }, + } + + +def test_sanitize_client_context_limits_capabilities_and_preferences(): + client = sanitize_client_context( + { + "capabilities": {f"cap_{i:02d}": True for i in range(40)}, + "preferences": {f"pref_{i:02d}": "present" for i in range(40)}, + } + ) + + assert client is not None + assert list(client["capabilities"]) == [f"cap_{i:02d}" for i in range(32)] + assert list(client["preferences"]) == [f"pref_{i:02d}" for i in range(32)] + + +def test_render_client_context_for_prompt_escapes_and_formats(): + rendered = render_client_context_for_prompt( + { + "name": "analytics ", + "capabilities": { + "artifacts": True, + "images": False, + }, + "preferences": { + "csv": "present ", + "concise": True, + }, + } + ) + + assert rendered == "\n".join( + [ + "", + "name: analytics <frontend>", + "capabilities: artifacts", + "unsupported_capabilities: images", + "preferences: concise=true; csv=present <download>", + "", + ] + ) + + +def test_render_client_context_for_prompt_returns_none_when_no_safe_fields(): + assert render_client_context_for_prompt({"access_token": "secret"}) is None + assert render_client_context_for_prompt("bad-client-context") is None diff --git a/backend/tests/test_dynamic_context_middleware.py b/backend/tests/test_dynamic_context_middleware.py index a82f0891a7..11d76934d9 100644 --- a/backend/tests/test_dynamic_context_middleware.py +++ b/backend/tests/test_dynamic_context_middleware.py @@ -21,8 +21,8 @@ def _make_middleware(**kwargs) -> DynamicContextMiddleware: return DynamicContextMiddleware(**kwargs) -def _fake_runtime(): - return SimpleNamespace(context={}) +def _fake_runtime(context=None): + return SimpleNamespace(context=context or {}) def _reminder_msg(content: str, msg_id: str) -> HumanMessage: @@ -86,6 +86,95 @@ def test_memory_included_when_present(): assert result["messages"][1].content == "Hi" +def test_client_context_included_when_present(): + mw = _make_middleware() + state = {"messages": [HumanMessage(content="Show me the data", id="msg-1")]} + runtime = _fake_runtime( + { + "client": { + "name": "custom-analytics-frontend", + "access_token": "secret", + "capabilities": { + "artifacts": True, + "csv_download": True, + "images": False, + }, + "preferences": { + "csv": "present", + }, + } + } + ) + + with mock.patch("deerflow.agents.lead_agent.prompt._get_memory_context", return_value=""), mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt: + mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday" + result = mw.before_agent(state, runtime) + + reminder_content = result["messages"][0].content + assert "" in reminder_content + assert "name: custom-analytics-frontend" in reminder_content + assert "capabilities: artifacts, csv_download" in reminder_content + assert "unsupported_capabilities: images" in reminder_content + assert "preferences: csv=present" in reminder_content + assert "access_token" not in reminder_content + assert reminder_content.index("") < reminder_content.index("") + + +def test_client_context_update_injected_when_context_changes_same_day(): + mw = _make_middleware() + reminder_content = "\n2026-05-08, Friday\n" + state = { + "messages": [ + _reminder_msg(reminder_content, "msg-1"), + HumanMessage(content="Hello", id="msg-1__user"), + AIMessage(content="Hi there"), + HumanMessage(content="Follow-up", id="msg-2"), + ] + } + runtime = _fake_runtime({"client": {"name": "analytics-ui", "capabilities": {"csv_download": True}}}) + + with mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt: + mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday" + result = mw.before_agent(state, runtime) + + assert result is not None + reminder = result["messages"][0] + assert reminder.id == "msg-2" + assert "" not in reminder.content + assert "" in reminder.content + assert "name: analytics-ui" in reminder.content + assert result["messages"][1].content == "Follow-up" + + +def test_client_context_cleared_when_runtime_omits_previous_context(): + mw = _make_middleware() + reminder_content = "\n".join( + [ + "", + "", + "name: analytics-ui", + "", + "2026-05-08, Friday", + "", + ] + ) + state = { + "messages": [ + _reminder_msg(reminder_content, "msg-1"), + HumanMessage(content="Hello", id="msg-1__user"), + AIMessage(content="Hi there"), + HumanMessage(content="Follow-up", id="msg-2"), + ] + } + + with mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt: + mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday" + result = mw.before_agent(state, _fake_runtime()) + + assert result is not None + assert "not provided" in result["messages"][0].content + + # --------------------------------------------------------------------------- # Frozen-snapshot: no re-injection within a session # --------------------------------------------------------------------------- @@ -291,6 +380,28 @@ def test_midnight_crossing_injects_date_update_as_separate_message(): assert msgs[1].content == "Good morning" +def test_midnight_crossing_keeps_client_context_before_date_update(): + mw = _make_middleware() + reminder_content = "\n2026-05-08, Friday\n" + state = { + "messages": [ + _reminder_msg(reminder_content, "msg-1"), + HumanMessage(content="Hello", id="msg-1__user"), + AIMessage(content="Response"), + HumanMessage(content="Good morning", id="msg-2"), + ] + } + runtime = _fake_runtime({"client": {"name": "analytics-ui", "capabilities": {"charts": True}}}) + + with mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt: + mock_dt.now.return_value.strftime.return_value = "2026-05-09, Saturday" + result = mw.before_agent(state, runtime) + + content = result["messages"][0].content + assert "name: analytics-ui" in content + assert content.index("") < content.index("") + + def test_midnight_crossing_id_swap(): """Date-update reminder uses original ID; user message uses {id}__user.""" mw = _make_middleware() diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index aa9e20e780..d10089a631 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -281,6 +281,68 @@ def test_merge_run_context_overrides_propagates_to_runtime_context(): assert "thread_id" not in config["context"] +def test_merge_run_context_overrides_propagates_sanitized_client_context_to_runtime_only(): + """``context.client`` should be available to runtime consumers, not legacy configurable readers.""" + from app.gateway.services import build_run_config, merge_run_context_overrides + + config = build_run_config("thread-1", None, None) + merge_run_context_overrides( + config, + { + "client": { + "name": "custom-analytics-frontend", + "access_token": "must-not-propagate", + "capabilities": { + "artifacts": True, + "csv_download": True, + "charts": False, + "bad key": True, + "coerced": "true", + }, + "preferences": { + "csv": "present", + "chart": "skip", + "nested": {"bad": "value"}, + }, + } + }, + ) + + assert config["context"]["client"] == { + "name": "custom-analytics-frontend", + "capabilities": { + "artifacts": True, + "charts": False, + "csv_download": True, + }, + "preferences": { + "chart": "skip", + "csv": "present", + }, + } + assert "client" not in config["configurable"] + + +def test_merge_run_context_overrides_preserves_explicit_config_context_client(): + """Explicit ``config.context.client`` wins over top-level ``context.client``.""" + from app.gateway.services import build_run_config, merge_run_context_overrides + + config = build_run_config( + "thread-1", + {"context": {"client": {"name": "config-client", "capabilities": {"artifacts": False}}}}, + None, + ) + merge_run_context_overrides( + config, + {"client": {"name": "body-client", "capabilities": {"artifacts": True}}}, + ) + + assert config["context"]["client"] == { + "name": "config-client", + "capabilities": {"artifacts": False}, + } + + def test_merge_run_context_overrides_noop_for_empty_context(): from app.gateway.services import build_run_config, merge_run_context_overrides diff --git a/backend/tests/test_runtime_lifecycle_e2e.py b/backend/tests/test_runtime_lifecycle_e2e.py index 1eda351ec7..fbc82de0ca 100644 --- a/backend/tests/test_runtime_lifecycle_e2e.py +++ b/backend/tests/test_runtime_lifecycle_e2e.py @@ -57,6 +57,7 @@ class _RunController: def __init__(self) -> None: self.started = threading.Event() self.checkpoint_written = threading.Event() + self.blocked = threading.Event() self.cancelled = threading.Event() self.release = threading.Event() self.instances: list[_ScriptedAgent] = [] @@ -109,6 +110,7 @@ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=Fa yield _stream_item_for_mode(stream_mode, state) if self.block_after_first_chunk: + self.controller.blocked.set() try: while not self.controller.release.is_set(): await asyncio.sleep(0.05) @@ -560,6 +562,7 @@ def test_cancel_interrupt_stops_running_background_run(isolated_app): assert created.status_code == 200, created.text run_id = created.json()["run_id"] assert controller.started.wait(5), "fake agent never started" + assert controller.blocked.wait(5), "fake agent never entered its cancellable wait" cancelled = client.post( f"/api/threads/{thread_id}/runs/{run_id}/cancel?wait=true&action=interrupt", @@ -665,6 +668,7 @@ def test_cancel_rollback_restores_pre_run_checkpoint(isolated_app): assert created.status_code == 200, created.text run_id = created.json()["run_id"] assert controller.checkpoint_written.wait(5), "fake agent did not write in-run checkpoint" + assert controller.blocked.wait(5), "fake agent never entered its cancellable wait" during = client.get(f"/api/threads/{thread_id}/state") assert during.status_code == 200, during.text diff --git a/frontend/src/core/threads/types.ts b/frontend/src/core/threads/types.ts index dafb073494..a485086715 100644 --- a/frontend/src/core/threads/types.ts +++ b/frontend/src/core/threads/types.ts @@ -9,6 +9,12 @@ export interface AgentThreadState extends Record { todos?: Todo[]; } +export interface AgentClientContext { + name?: string; + capabilities?: Record; + preferences?: Record; +} + export interface AgentThreadContext extends Record { thread_id: string; model_name: string | undefined; @@ -17,6 +23,7 @@ export interface AgentThreadContext extends Record { subagent_enabled: boolean; reasoning_effort?: "minimal" | "low" | "medium" | "high"; agent_name?: string; + client?: AgentClientContext; } export interface AgentThread extends Thread {