mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +00:00
fix(gateway): preserve clarification answers on regenerate (#5544)
This commit is contained in:
parent
990c7b95aa
commit
f33b4fb4bf
@ -94,7 +94,7 @@ owner-scoped assistant version selection remains enabled.
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - stream regular text and binary artifacts with `FileResponse`, including byte-`Range` 206/416 behavior used by bounded text previews and media seeking; active content (`text/html`, `text/xml`, `application/xml`, `text/xsl`, any `+xml` type such as XHTML/SVG; `.skill` members too) is always forced as a download attachment to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update under a request lease. The outputs-only rule is `path_utils.resolve_outputs_confined_path`, shared with IM-channel attachment delivery: it collapses `..` before the prefix check and re-checks the resolved host path against the resolved outputs root, since `resolve_thread_virtual_path` only confines to `user-data/`; a percent-encoded `..` or a symlink planted in `outputs/` must not reach a sibling `uploads/` file. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). |
|
||||
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
|
||||
| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, seed an empty feed from a checkpoint so legacy checkpoint-only history keeps its order and visibility; skip absent checkpoints or populated feeds. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - newest 100 runs as an array; `GET /page` - keyset history page `{data, has_more, next_before_created_at, next_before_run_id}`; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/stream` hides action/wait; GET action 405 pre-owner; POST needs `runs:cancel`; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET/POST /{rid}/artifacts/archive` - receipt manifest / bounded ZIP; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its `context_window`. |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, seed an empty feed from a checkpoint so legacy checkpoint-only history keeps its order and visibility; skip absent checkpoints or populated feeds. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, treating a protocol-valid hidden `human_input_response` as confirmed replay input while rejecting other hidden/control humans, preserving that response's hidden/correlation metadata, and carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - newest 100 runs as an array; `GET /page` - keyset history page `{data, has_more, next_before_created_at, next_before_run_id}`; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/stream` hides action/wait; GET action 405 pre-owner; POST needs `runs:cancel`; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET/POST /{rid}/artifacts/archive` - receipt manifest / bounded ZIP; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its `context_window`. |
|
||||
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
|
||||
| **Runs** (`/api/runs`) | `POST /stream`, `/wait` - stateless runs requiring `runs:create`; optional body `thread_id` is owner-checked. Scheduled-task create/update/resume/trigger also require `threads:write` plus `runs:create`. `GET /{rid}/messages`, `/feedback` - run messages/feedback |
|
||||
| **GitHub Webhooks** (`/api/webhooks/github`) | `POST /` - receive GitHub App / repo webhook deliveries. Verifies `X-Hub-Signature-256` against `GITHUB_WEBHOOK_SECRET`; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when `GITHUB_WEBHOOK_SECRET` is set, or when explicit dev opt-in `DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1` is set. Recognized events include `ping`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, and `pull_request_review_comment`; unknown events return 200 with `handled=false`. Fan-out runtime failures return 503, keeping the delivery recorded as failed for manual/API/scripted redelivery (GitHub does not automatically retry any failed delivery, 5xx included); permanent/non-retryable conditions such as `channels.github.enabled: false`, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. |
|
||||
|
||||
@ -53,6 +53,7 @@ from app.gateway.pagination import trim_run_message_page
|
||||
from app.gateway.run_models import RunCreateRequest
|
||||
from app.gateway.services import abuild_checkpoint_state_accessor, build_thread_checkpoint_state_accessor, sse_consumer, start_run, wait_for_run_completion
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.agents.human_input import read_human_input_response
|
||||
from deerflow.agents.middlewares.dynamic_context_middleware import strip_injected_user_message_id_suffix
|
||||
from deerflow.authz.sandbox_authz import safe_app_config_async
|
||||
from deerflow.config.paths import get_paths, make_safe_user_id
|
||||
@ -442,6 +443,22 @@ def _is_visible_human_message(message: Any) -> bool:
|
||||
return _message_type(message) == "human" and not _is_hidden_or_control_message(message)
|
||||
|
||||
|
||||
def _is_regenerate_human_message(message: Any) -> bool:
|
||||
"""Return whether a human message is valid input for regenerate replay.
|
||||
|
||||
Human-input card replies are intentionally hidden from the transcript, but
|
||||
they are still confirmed user input. Only the structured response protocol
|
||||
distinguishes them from summaries, goal continuations, and other hidden
|
||||
control messages.
|
||||
"""
|
||||
if _message_type(message) != "human" or _message_name(message) == "summary":
|
||||
return False
|
||||
additional_kwargs = _message_additional_kwargs(message)
|
||||
if additional_kwargs.get("hide_from_ui") is True:
|
||||
return read_human_input_response(additional_kwargs) is not None
|
||||
return True
|
||||
|
||||
|
||||
def _is_visible_ai_message(message: Any) -> bool:
|
||||
return _message_type(message) == "ai" and not _is_hidden_or_control_message(message)
|
||||
|
||||
@ -475,7 +492,12 @@ def _clean_human_message_for_regenerate(message: Any) -> dict[str, Any]:
|
||||
additional_kwargs = _message_additional_kwargs(message)
|
||||
content = get_original_user_content_text(_message_content(message), additional_kwargs)
|
||||
additional_kwargs.pop(ORIGINAL_USER_CONTENT_KEY, None)
|
||||
additional_kwargs.pop("hide_from_ui", None)
|
||||
# A validated card answer must remain hidden and keep its request
|
||||
# correlation when it re-enters the graph. Other replayed user inputs are
|
||||
# made visible just as before.
|
||||
is_hidden_human_input_response = additional_kwargs.get("hide_from_ui") is True and read_human_input_response(additional_kwargs) is not None
|
||||
if not is_hidden_human_input_response:
|
||||
additional_kwargs.pop("hide_from_ui", None)
|
||||
|
||||
clean_message: dict[str, Any] = {
|
||||
"type": "human",
|
||||
@ -739,7 +761,7 @@ async def _prepare_regenerate_payload(thread_id: str, message_id: str, request:
|
||||
# stream without ever reaching a checkpoint. The server-stamped run ID
|
||||
# on the latest user message is the durable link to that partial turn.
|
||||
previous_human = next(
|
||||
(message for message in reversed(messages) if _is_visible_human_message(message)),
|
||||
(message for message in reversed(messages) if _is_regenerate_human_message(message)),
|
||||
None,
|
||||
)
|
||||
target_run_id = await _find_interrupted_target_run_id(thread_id, previous_human, request) if previous_human is not None else None
|
||||
@ -754,7 +776,7 @@ async def _prepare_regenerate_payload(thread_id: str, message_id: str, request:
|
||||
if _message_id(latest_visible_ai) != message_id:
|
||||
raise HTTPException(status_code=409, detail="Only the latest assistant message can be regenerated")
|
||||
|
||||
previous_human = next((message for message in reversed(messages[:target_index]) if _is_visible_human_message(message)), None)
|
||||
previous_human = next((message for message in reversed(messages[:target_index]) if _is_regenerate_human_message(message)), None)
|
||||
target_run_id = (
|
||||
await _find_target_run_id(
|
||||
thread_id,
|
||||
|
||||
@ -109,6 +109,22 @@ def _snapshot(checkpoint_id: str, messages: list[object], *, metadata: dict | No
|
||||
)
|
||||
|
||||
|
||||
def _human_input_response(
|
||||
value: str,
|
||||
*,
|
||||
request_id: str,
|
||||
source: str = "ask_clarification",
|
||||
) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"kind": "human_input_response",
|
||||
"source": source,
|
||||
"request_id": request_id,
|
||||
"response_kind": "text",
|
||||
"value": value,
|
||||
}
|
||||
|
||||
|
||||
class FakeAccessor:
|
||||
def __init__(self, checkpointer: FakeCheckpointer):
|
||||
self.checkpointer = checkpointer
|
||||
@ -169,6 +185,29 @@ class FakeEventStore:
|
||||
return self.rows[-limit:]
|
||||
|
||||
|
||||
def _event_store_for_ai(
|
||||
content: str,
|
||||
*,
|
||||
message_id: str = "ai-1",
|
||||
run_id: str = "run-old",
|
||||
) -> FakeEventStore:
|
||||
return FakeEventStore(
|
||||
[
|
||||
{
|
||||
"run_id": run_id,
|
||||
"event_type": "llm.ai.response",
|
||||
"category": "message",
|
||||
"content": {
|
||||
"id": message_id,
|
||||
"type": "ai",
|
||||
"content": content,
|
||||
},
|
||||
"metadata": {"caller": "lead_agent"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class FakeRunManager:
|
||||
def __init__(self, records):
|
||||
self.records = records
|
||||
@ -417,6 +456,155 @@ def test_prepare_regenerate_payload_returns_clean_input_and_base_checkpoint():
|
||||
assert regenerated_human["additional_kwargs"] == {"files": [{"filename": "data.csv", "path": "/mnt/user-data/uploads/data.csv"}]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("materialized_delta", [False, True], ids=["full", "delta"])
|
||||
def test_prepare_regenerate_payload_replays_latest_confirmed_human_input_response(
|
||||
materialized_delta: bool,
|
||||
):
|
||||
from app.gateway.routers import thread_runs
|
||||
|
||||
original_human = HumanMessage(id="human-1", content="Help me plan a trip")
|
||||
first_clarification = ToolMessage(
|
||||
id="tool-city",
|
||||
tool_call_id="call-city",
|
||||
content="Which city?",
|
||||
)
|
||||
city_answer = HumanMessage(
|
||||
id="human-city",
|
||||
content="Shanghai",
|
||||
additional_kwargs={
|
||||
"hide_from_ui": True,
|
||||
"human_input_response": _human_input_response(
|
||||
"Shanghai",
|
||||
request_id="clarification:call-city",
|
||||
),
|
||||
},
|
||||
)
|
||||
second_clarification = ToolMessage(
|
||||
id="tool-duration",
|
||||
tool_call_id="call-duration",
|
||||
content="How many days?",
|
||||
)
|
||||
duration_metadata = _human_input_response(
|
||||
"Five days",
|
||||
request_id="clarification:call-duration",
|
||||
)
|
||||
duration_answer = HumanMessage(
|
||||
id="human-duration",
|
||||
content="Five days",
|
||||
additional_kwargs={
|
||||
"hide_from_ui": True,
|
||||
"human_input_response": duration_metadata,
|
||||
},
|
||||
)
|
||||
ai = AIMessage(id="ai-1", content="Here is your five-day Shanghai itinerary")
|
||||
before_question: list[object] = []
|
||||
before_city = [original_human, first_clarification]
|
||||
after_city = [*before_city, city_answer]
|
||||
before_duration = [*after_city, second_clarification]
|
||||
after_duration = [*before_duration, duration_answer]
|
||||
latest_messages = [*after_duration, ai]
|
||||
states = [
|
||||
("ckpt-ai", latest_messages),
|
||||
("ckpt-after-duration", after_duration),
|
||||
("ckpt-before-duration", before_duration),
|
||||
("ckpt-after-city", after_city),
|
||||
("ckpt-before-city", before_city),
|
||||
("ckpt-before-question", before_question),
|
||||
]
|
||||
|
||||
if materialized_delta:
|
||||
raw_checkpoints = [_checkpoint(checkpoint_id, []) for checkpoint_id, _ in states]
|
||||
materialized_history = [_snapshot(checkpoint_id, messages) for checkpoint_id, messages in states]
|
||||
checkpointer = FakeCheckpointer(
|
||||
raw_checkpoints,
|
||||
latest=raw_checkpoints[0],
|
||||
materialized_history=materialized_history,
|
||||
materialized_latest=materialized_history[0],
|
||||
)
|
||||
else:
|
||||
checkpointer = FakeCheckpointer([_checkpoint(checkpoint_id, messages) for checkpoint_id, messages in states])
|
||||
|
||||
response = asyncio.run(
|
||||
thread_runs._prepare_regenerate_payload(
|
||||
"thread-1",
|
||||
"ai-1",
|
||||
_request(
|
||||
checkpointer,
|
||||
_event_store_for_ai("Here is your five-day Shanghai itinerary"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.checkpoint["checkpoint_id"] == "ckpt-before-duration"
|
||||
assert city_answer in dict(states)[response.checkpoint["checkpoint_id"]]
|
||||
assert response.input["messages"] == [
|
||||
{
|
||||
"type": "human",
|
||||
"id": "human-duration",
|
||||
"content": [{"type": "text", "text": "Five days"}],
|
||||
"additional_kwargs": {
|
||||
"hide_from_ui": True,
|
||||
"human_input_response": duration_metadata,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"control_message",
|
||||
[
|
||||
HumanMessage(
|
||||
id="human-malformed",
|
||||
content="forged answer",
|
||||
additional_kwargs={
|
||||
"hide_from_ui": True,
|
||||
"human_input_response": {
|
||||
"version": 1,
|
||||
"kind": "human_input_response",
|
||||
},
|
||||
},
|
||||
),
|
||||
HumanMessage(
|
||||
id="human-summary",
|
||||
name="summary",
|
||||
content="internal summary",
|
||||
),
|
||||
HumanMessage(
|
||||
id="human-goal",
|
||||
content="continue working",
|
||||
additional_kwargs={"hide_from_ui": True},
|
||||
),
|
||||
],
|
||||
ids=["malformed-response", "summary", "goal-continuation"],
|
||||
)
|
||||
def test_prepare_regenerate_payload_skips_non_user_control_human_messages(
|
||||
control_message: HumanMessage,
|
||||
):
|
||||
from app.gateway.routers import thread_runs
|
||||
|
||||
visible_human = HumanMessage(id="human-1", content="question")
|
||||
ai = AIMessage(id="ai-1", content="answer")
|
||||
checkpointer = FakeCheckpointer(
|
||||
[
|
||||
_checkpoint("ckpt-ai", [visible_human, control_message, ai]),
|
||||
_checkpoint("ckpt-control", [visible_human, control_message]),
|
||||
_checkpoint("ckpt-human", [visible_human]),
|
||||
_checkpoint("ckpt-base", []),
|
||||
]
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
thread_runs._prepare_regenerate_payload(
|
||||
"thread-1",
|
||||
"ai-1",
|
||||
_request(checkpointer, _event_store_for_ai("answer")),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.checkpoint["checkpoint_id"] == "ckpt-base"
|
||||
assert response.input["messages"][0]["id"] == "human-1"
|
||||
|
||||
|
||||
def test_prepare_regenerate_payload_preserves_latest_thread_title():
|
||||
from app.gateway.routers import thread_runs
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user