diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f199e5517..50bdcade42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1397,6 +1397,13 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. ### Fixed +- **threads:** `POST /api/threads` now rejects an `assistant_id` that cannot + name an agent with `422`. It used to store the value, and every later read + of the thread (`GET` thread, state, history) answered + `500 Internal Server Error` until the thread was deleted. Threads saved that + way before this fix are read with the lead agent's schema, and a branch of + such a thread stores no `assistant_id`, so it uses the lead agent. ([#7445]) + - **frontend:** Accounts whose email the backend accepted but the frontend's stricter pattern did not (for example `a&b@example.com`, `a@b.c` or a Unicode address) now get the normal workspace. Their sessions used to show @@ -13075,4 +13082,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7364]: https://github.com/bytedance/deer-flow/pull/7364 [#7400]: https://github.com/bytedance/deer-flow/pull/7400 [#7410]: https://github.com/bytedance/deer-flow/pull/7410 +[#7445]: https://github.com/bytedance/deer-flow/pull/7445 [#7449]: https://github.com/bytedance/deer-flow/pull/7449 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 5aaa119f29..0228124e5b 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -1112,6 +1112,12 @@ ### 修复 +- **线程:** `POST /api/threads` 现在会以 `422` 拒绝无法对应任何智能体的 + `assistant_id`。此前该值会被直接保存,之后读取该线程(`GET` 线程、状态、历史) + 都会返回 `500 Internal Server Error`,直到删除线程。修复前已这样保存的线程 + 改为按主智能体的状态结构读取;从这类线程创建的分支不保存 `assistant_id`,因此使用 + 主智能体。([#7445]) + - **前端:** 后端接受、但前端更严格的格式检查不接受的邮箱账号(如 `a&b@example.com`、`a@b.c` 或含 Unicode 的地址)现在可以正常进入工作区。此前这些 账号的会话会显示网关离线界面,且只要存在一个这样的账号,所有管理员的 @@ -10437,4 +10443,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7364]: https://github.com/bytedance/deer-flow/pull/7364 [#7400]: https://github.com/bytedance/deer-flow/pull/7400 [#7410]: https://github.com/bytedance/deer-flow/pull/7410 +[#7445]: https://github.com/bytedance/deer-flow/pull/7445 [#7449]: https://github.com/bytedance/deer-flow/pull/7449 diff --git a/README.md b/README.md index e1e6ad97ed..297d994f2e 100644 --- a/README.md +++ b/README.md @@ -2364,6 +2364,8 @@ Streaming Markdown responses animate only newly arrived words; text that is alre In the Web UI, completed assistant turns can be branched into a new main conversation. Automatically inherited branch titles use the next free language-neutral numeric suffix (`Title (2)`, then `Title (3)` for another sibling or a branch of the numbered conversation) so generated sibling titles remain distinct without persisting a locale-specific label; matching explicit or renamed sibling titles also reserve their displayed suffix even though they carry no generated sequence metadata. An explicit API title is preserved. Renaming a branch clears its generated sequence, so its next automatic branch starts from the renamed title at `(2)`. Recent chats also groups loaded branches directly beneath a loaded parent with subtle tree connectors. Missing parents, malformed or cyclic lineage, and branches in a different pinned state stay visible at the top level instead of being hidden or moved across the pinned boundary. The new thread starts from that turn's checkpoint and keeps the preceding replay checkpoint, so the branched response can be regenerated immediately. The latest response can also be regenerated after an interruption, even when its streamed partial text never reached a checkpoint. Stopping mid-answer retains the assistant text that was already streamed in the thread's message feed, so refreshing no longer discards visible progress; that partial text remains outside authoritative completed checkpoint state. Regenerating the latest response preserves the thread's current title, including a title you renamed manually after the original response. Legacy or imported histories without checkpoint parent links use a bounded chronological fallback; if no earlier replay checkpoint exists, branching still succeeds with the legacy single-checkpoint shape, while regeneration remains unavailable for that inherited response. Existing single-checkpoint branches are left unchanged rather than attempting an unsafe checkpoint copy. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation. +Branches preserve the source conversation's valid agent selection. A branch of a legacy conversation with an invalid agent ID uses the lead agent instead. + The Web UI reports completed task time once per run. This is total wall-clock time—including model reasoning, tool calls, and waiting—not a per-step or model-only thinking duration. Reasoning content remains available through its own separate disclosure. While a response streams, reasoning-only messages stay in the processing panel, including Anthropic thinking blocks. Once answer content arrives alongside reasoning, it appears in an assistant bubble. diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md index 6697d8c3f6..6a9501b99c 100644 --- a/backend/app/gateway/routers/AGENTS.md +++ b/backend/app/gateway/routers/AGENTS.md @@ -1,5 +1,9 @@ # Router contracts +Branch metadata inherits valid `assistant_id` values verbatim. Validate with +`custom_agent_name` and clear invalid legacy values to null in the new row; +leave the source record unchanged. + Console usage attributes legacy rows without a per-model usage map to `model_name`, including their recorded `total_input_tokens`. Accumulate those inputs with newer usage buckets for the same model; do not infer cache hits. diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index b2a873be6a..ddfecb9fd6 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -44,6 +44,7 @@ from app.gateway.services import ( build_checkpoint_state_mutation_accessor, build_thread_checkpoint_state_accessor, build_thread_checkpoint_state_mutation_accessor, + custom_agent_name, reserve_checkpoint_write, strip_server_owned_state_metadata, ) @@ -497,6 +498,14 @@ class ThreadCreateRequest(BaseModel): _strip_reserved = field_validator("metadata")(classmethod(lambda cls, v: _strip_reserved_metadata(v))) + @field_validator("assistant_id") + @classmethod + def _assistant_id_names_an_agent(cls, value: str | None) -> str | None: + # State reads build a run config from the stored value, so reject it + # here as run creation does. The caller's spelling is kept. + custom_agent_name(value) + return value + class ThreadSearchRequest(BaseModel): """Request body for searching threads.""" @@ -1204,11 +1213,19 @@ async def _branch_thread_with_reservation( logger.exception("Failed to write branch checkpoint for thread %s", sanitize_log_param(new_thread_id)) raise HTTPException(status_code=500, detail="Failed to create branch") from None + branch_assistant_id = source_record.get("assistant_id") + try: + custom_agent_name(branch_assistant_id) + except ValueError: + # Read compatibility for legacy records must not copy an invalid id + # into a new thread. Preserve every valid spelling as create does. + branch_assistant_id = None + async def _write_branch_row(project_id: str | None) -> None: try: await thread_store.create( new_thread_id, - assistant_id=source_record.get("assistant_id"), + assistant_id=branch_assistant_id, display_name=display_name, metadata=branch_metadata, project_id=project_id, diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index b84ce812bd..f34b4e5ffc 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -1294,6 +1294,23 @@ def _clamp_recursion_limit(value: Any, max_limit: int, default_limit: int) -> in return min(value, max_limit) +def custom_agent_name(assistant_id: str | None) -> str | None: + """Return the custom agent *assistant_id* selects, or ``None`` for the lead agent. + + Custom names are normalized (trimmed, lowercased, ``_`` to ``-``) and must + then contain only letters, digits and hyphens. + + Raises: + ValueError: *assistant_id* cannot name an agent. + """ + if not assistant_id or assistant_id == _DEFAULT_ASSISTANT_ID: + return None + normalized = assistant_id.strip().lower().replace("_", "-") + if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): + raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.") + return normalized + + def build_run_config( thread_id: str, request_config: dict[str, Any] | None, @@ -1391,10 +1408,8 @@ def build_run_config( # Inject custom agent name when the caller specified a non-default assistant. # Honour an explicit agent_name in either runtime options container. - if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID: - normalized = assistant_id.strip().lower().replace("_", "-") - if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): - raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.") + normalized = custom_agent_name(assistant_id) + if normalized is not None: configurable = config.setdefault("configurable", {}) runtime_context = config.setdefault("context", {}) explicit_agent_name: str | None = None @@ -1628,6 +1643,14 @@ def build_checkpoint_state_accessor( ) -> tuple[CheckpointStateAccessor, dict[str, Any]]: """Build the mode-selected lead graph used for materialized checkpoint state.""" ctx = get_run_context(request) + try: + custom_agent_name(assistant_id) + except ValueError: + # Thread creation did not always validate assistant_id, so a stored + # one may name no agent. Run creation rejects such a name, so no run + # ran as it: read with the lead agent's schema instead of failing. + logger.warning("Thread %s records an invalid assistant_id; reading its state with the lead agent schema", sanitize_log_param(thread_id)) + assistant_id = None config = build_run_config(thread_id, None, None, assistant_id=assistant_id) configurable = config.setdefault("configurable", {}) configurable["checkpoint_ns"] = checkpoint_ns diff --git a/backend/docs/API.md b/backend/docs/API.md index 0041d10aa9..c0e4ce8010 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -181,6 +181,12 @@ Content-Type: application/json } ``` +Optional `assistant_id` binds the thread to `lead_agent` or a custom agent. +After trimming, lowercasing and mapping `_` to `-`, a custom name must contain +only letters, digits and hyphens; any other value is rejected with `422`. +Branches preserve valid stored IDs, including their original spelling. If a +legacy source has an invalid ID, its new branch stores `null` and uses the lead agent. + **Response:** ```json { diff --git a/backend/tests/test_extension_agent_runs.py b/backend/tests/test_extension_agent_runs.py index 230c1b4f03..87b1e39245 100644 --- a/backend/tests/test_extension_agent_runs.py +++ b/backend/tests/test_extension_agent_runs.py @@ -342,6 +342,15 @@ async def test_foreign_thread_and_forged_privileged_input_are_rejected(runtime): await runs.start(thread_id=thread, input={}, context={"agent_name": "different"}) +@pytest.mark.asyncio +async def test_create_thread_rejects_an_assistant_id_that_names_no_agent(runtime): + runs, state, _, _, _ = runtime + with pytest.raises(AgentRunError) as rejected: + await runs.create_thread(assistant_id="bad name!", thread_id="invalid-agent") + assert rejected.value.status_code == 422 + assert await state.thread_store.get("invalid-agent", user_id=None) is None + + @pytest.mark.asyncio async def test_custom_agent_binding_and_permission_rechecks_use_current_user(runtime, monkeypatch): from app.gateway.routers.thread_runs import RunResponse diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 5db9d929be..7e4837dfd4 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -1454,6 +1454,55 @@ def test_build_checkpoint_state_accessor_uses_frozen_mode_and_binds_runtime_pers assert config["configurable"]["checkpoint_id"] == checkpoint_id +def test_build_checkpoint_state_accessor_reads_an_invalid_stored_assistant_id_as_the_lead_agent(_stub_app_config, caplog): + """Thread records written before creation validated assistant_id must stay readable.""" + import logging + from types import SimpleNamespace + from unittest.mock import patch + + from app.gateway.services import build_checkpoint_state_accessor + from deerflow.config.app_config import get_app_config + + class FakeGraph: + checkpointer = None + store = None + + graph = FakeGraph() + ctx = SimpleNamespace(checkpointer=object(), store=object(), checkpoint_channel_mode="delta", app_config=get_app_config()) + request = SimpleNamespace(state=SimpleNamespace(checkpoint_channel_mode="delta")) + + with ( + patch("app.gateway.services.get_run_context", return_value=ctx), + patch("app.gateway.services.resolve_agent_factory", return_value=lambda *, config: graph) as resolve, + caplog.at_level(logging.WARNING, logger="app.gateway.services"), + ): + accessor, config = build_checkpoint_state_accessor(request, thread_id="legacy-thread", assistant_id="bad name!") + + resolve.assert_called_once_with(None) + assert accessor.graph is graph + assert "agent_name" not in config["configurable"] + assert "agent_name" not in config["context"] + assert any("invalid assistant_id" in record.getMessage() for record in caplog.records) + + +@pytest.mark.parametrize( + ("assistant_id", "expected"), + [(None, None), ("", None), ("lead_agent", None), ("Research_Agent", "research-agent"), (" finalis ", "finalis")], +) +def test_custom_agent_name_normalizes_custom_names(assistant_id, expected): + from app.gateway.services import custom_agent_name + + assert custom_agent_name(assistant_id) == expected + + +@pytest.mark.parametrize("assistant_id", ["bad name!", " ", "a/b", "../etc", "naïve"]) +def test_custom_agent_name_rejects_names_no_agent_can_have(assistant_id): + from app.gateway.services import custom_agent_name + + with pytest.raises(ValueError, match="Invalid assistant_id"): + custom_agent_name(assistant_id) + + def test_build_checkpoint_state_accessor_accepts_lead_agent_assembly_factory(_stub_app_config): """Checkpoint reads accept the descriptor-carrying Gateway factory result.""" from types import SimpleNamespace diff --git a/backend/tests/test_runtime_lifecycle_e2e.py b/backend/tests/test_runtime_lifecycle_e2e.py index ae62951694..8c551cda8d 100644 --- a/backend/tests/test_runtime_lifecycle_e2e.py +++ b/backend/tests/test_runtime_lifecycle_e2e.py @@ -16,6 +16,7 @@ import threading import time import uuid from contextlib import suppress +from functools import partial from pathlib import Path from typing import Any from unittest.mock import patch @@ -636,6 +637,110 @@ def test_malformed_run_body_is_rejected_before_a_run_is_created(isolated_app, ov assert runs.json() == [] +@pytest.mark.parametrize("assistant_id", ["bad name!", " ", "a/b", "../etc"]) +def test_thread_create_rejects_an_assistant_id_that_names_no_agent(isolated_app, assistant_id): + """Such an id used to be stored, and every state read of the thread then answered 500.""" + from starlette.testclient import TestClient + + with TestClient(isolated_app, raise_server_exceptions=False) as client: + csrf_token = _register_user(client) + thread_id = str(uuid.uuid4()) + + response = client.post( + "/api/threads", + json={"thread_id": thread_id, "assistant_id": assistant_id}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 422, response.text + [error] = response.json()["detail"] + assert error["loc"] == ["body", "assistant_id"] + assert f"Invalid assistant_id {assistant_id!r}" in error["msg"] + assert client.portal.call(partial(isolated_app.state.thread_store.get, thread_id, user_id=None)) is None + + +@pytest.mark.parametrize("assistant_id", [None, "", "lead_agent", "Research_Agent"]) +def test_thread_create_keeps_a_valid_assistant_id_as_sent(isolated_app, assistant_id): + from starlette.testclient import TestClient + + with TestClient(isolated_app, raise_server_exceptions=False) as client: + csrf_token = _register_user(client) + thread_id = str(uuid.uuid4()) + + response = client.post( + "/api/threads", + json={"thread_id": thread_id, "assistant_id": assistant_id}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 200, response.text + assert client.portal.call(partial(isolated_app.state.thread_store.get, thread_id, user_id=None))["assistant_id"] == assistant_id + assert client.get(f"/api/threads/{thread_id}/state").status_code == 200 + + +def test_thread_recorded_with_an_invalid_assistant_id_stays_readable(isolated_app): + """A record written before creation validated assistant_id must not keep answering 500.""" + from starlette.testclient import TestClient + + with TestClient(isolated_app, raise_server_exceptions=False) as client: + csrf_token = _register_user(client) + user_id = client.get("/api/v1/auth/me").json()["id"] + thread_id = str(uuid.uuid4()) + client.portal.call(partial(isolated_app.state.thread_store.create, thread_id, assistant_id="bad name!", user_id=user_id)) + state = {"messages": [HumanMessage(content="hello", id="legacy-human")], "title": "Legacy thread"} + client.portal.call(partial(_write_checkpoint, isolated_app.state.checkpointer, thread_id=thread_id, state=state)) + + thread = client.get(f"/api/threads/{thread_id}") + thread_state = client.get(f"/api/threads/{thread_id}/state") + history = client.post(f"/api/threads/{thread_id}/history", json={}, headers={"X-CSRF-Token": csrf_token}) + + assert thread.status_code == 200, thread.text + assert thread.json()["values"]["title"] == "Legacy thread" + assert thread_state.status_code == 200, thread_state.text + assert thread_state.json()["values"]["title"] == "Legacy thread" + assert history.status_code == 200, history.text + assert [entry["values"]["title"] for entry in history.json()] == ["Legacy thread"] + + +@pytest.mark.parametrize( + "assistant_id,expected", + [("bad name!", None), (" ", None), ("a/b", None), ("../etc", None), (None, None), ("", ""), ("lead_agent", "lead_agent"), ("Research_Agent", "Research_Agent"), ("ghost-agent", "ghost-agent")], +) +def test_branch_inherits_only_a_valid_assistant_id(isolated_app, assistant_id, expected): + """Branches must not copy invalid legacy ids into a newly created metadata row.""" + from starlette.testclient import TestClient + + with TestClient(isolated_app, raise_server_exceptions=False) as client: + csrf_token = _register_user(client) + user_id = client.get("/api/v1/auth/me").json()["id"] + thread_id = str(uuid.uuid4()) + client.portal.call(partial(isolated_app.state.thread_store.create, thread_id, assistant_id=assistant_id, user_id=user_id)) + state = { + "messages": [HumanMessage(content="hello", id="legacy-human"), AIMessage(content="answer", id="legacy-answer")], + "title": "Legacy thread", + } + client.portal.call(partial(_write_checkpoint, isolated_app.state.checkpointer, thread_id=thread_id, state=state)) + + branch = client.post( + f"/api/threads/{thread_id}/branches", + json={"message_id": "legacy-answer"}, + headers={"X-CSRF-Token": csrf_token}, + ) + + assert branch.status_code == 200, branch.text + branch_id = branch.json()["thread_id"] + source_record = client.portal.call(partial(isolated_app.state.thread_store.get, thread_id, user_id=user_id)) + branch_record = client.portal.call(partial(isolated_app.state.thread_store.get, branch_id, user_id=user_id)) + assert source_record["assistant_id"] == assistant_id + assert branch_record["assistant_id"] == expected + assert branch_record["user_id"] == user_id + assert branch.json()["parent_thread_id"] == thread_id + + branch_state = client.get(f"/api/threads/{branch_id}/state") + assert branch_state.status_code == 200, branch_state.text + assert [message["id"] for message in branch_state.json()["values"]["messages"]] == ["legacy-human", "legacy-answer"] + + def test_stream_run_executes_real_lead_agent_setup_agent_business_path(isolated_app, isolated_deer_flow_home: Path): """A runtime stream should execute real lead-agent business code and tools.""" from starlette.testclient import TestClient