fix(gateway): preserve clarification answers on regenerate (#5544)

This commit is contained in:
ZJPex 2026-09-19 10:18:13 +08:00 committed by GitHub
parent 990c7b95aa
commit f33b4fb4bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 214 additions and 4 deletions

View File

@ -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. |

View File

@ -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,

View File

@ -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