mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 16:37:55 +00:00
fix(runtime): resume original title when regenerating (#4480)
* fix(runtime): rusume original title when regenerating * test(runtime): cover regenerated title sync
This commit is contained in:
parent
26ba0b9e6a
commit
625c07b993
@ -778,7 +778,7 @@ Interrupted first-turn runs still persist a fallback conversation title, so stop
|
||||
|
||||
Streaming Markdown responses animate only newly arrived words; text that is already visible is not faded out and replayed when the next chunk extends the same block.
|
||||
|
||||
In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint and keeps the preceding replay checkpoint, so the branched response can be regenerated immediately. 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.
|
||||
In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint and keeps the preceding replay checkpoint, so the branched response can be regenerated immediately. 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@ -439,7 +439,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
|
||||
| **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; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `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 /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate 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 |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest 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); `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `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 /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate 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 |
|
||||
| **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` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||
| **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. |
|
||||
|
||||
@ -490,8 +490,17 @@ async def _prepare_regenerate_payload(thread_id: str, message_id: str, request:
|
||||
"regenerate_from_run_id": target_run_id,
|
||||
"regenerate_checkpoint_id": checkpoint["checkpoint_id"],
|
||||
}
|
||||
regenerate_input: dict[str, Any] = {"messages": [_clean_human_message_for_regenerate(previous_human)]}
|
||||
latest_values = latest_checkpoint.values if isinstance(latest_checkpoint.values, dict) else {}
|
||||
latest_title = latest_values.get("title")
|
||||
if isinstance(latest_title, str) and latest_title:
|
||||
# Regenerate resumes from the checkpoint before the target human turn.
|
||||
# That checkpoint can predate a manual rename, so replay the current
|
||||
# title as graph input instead of letting checkpoint rollback restore
|
||||
# the older automatically generated title (#4457).
|
||||
regenerate_input["title"] = latest_title
|
||||
return RegeneratePrepareResponse(
|
||||
input={"messages": [_clean_human_message_for_regenerate(previous_human)]},
|
||||
input=regenerate_input,
|
||||
checkpoint=checkpoint,
|
||||
metadata=metadata,
|
||||
target_run_id=target_run_id,
|
||||
|
||||
@ -41,6 +41,7 @@ def anyio_backend():
|
||||
|
||||
class _DeltaChannelState(TypedDict):
|
||||
messages: Annotated[list[AnyMessage], DeltaChannel(merge_message_writes, snapshot_frequency=1000)]
|
||||
title: str | None
|
||||
|
||||
|
||||
class _FullChannelState(TypedDict):
|
||||
@ -226,12 +227,18 @@ async def test_run_agent_streams_from_the_linearized_delta_resume(tmp_path):
|
||||
branch_writer = CheckpointStateAccessor.bind(mutation_graph, checkpointer, mode="delta")
|
||||
replay_base_config = await branch_writer.aupdate(
|
||||
_run_config("worker-branch"),
|
||||
{"messages": Overwrite(list(source_pre_turn.values["messages"]))},
|
||||
{
|
||||
"messages": Overwrite(list(source_pre_turn.values["messages"])),
|
||||
"title": "Original title",
|
||||
},
|
||||
as_node="branch",
|
||||
)
|
||||
await branch_writer.aupdate(
|
||||
replay_base_config,
|
||||
{"messages": Overwrite(list(source_head.values["messages"]))},
|
||||
{
|
||||
"messages": Overwrite(list(source_head.values["messages"])),
|
||||
"title": "User renamed title",
|
||||
},
|
||||
as_node="branch",
|
||||
)
|
||||
|
||||
@ -247,6 +254,10 @@ async def test_run_agent_streams_from_the_linearized_delta_resume(tmp_path):
|
||||
publish_end=AsyncMock(),
|
||||
cleanup=AsyncMock(),
|
||||
)
|
||||
thread_store = SimpleNamespace(
|
||||
update_display_name=AsyncMock(),
|
||||
update_status=AsyncMock(),
|
||||
)
|
||||
created_graphs: list[Any] = []
|
||||
|
||||
def agent_factory(*, config):
|
||||
@ -260,9 +271,12 @@ async def test_run_agent_streams_from_the_linearized_delta_resume(tmp_path):
|
||||
bridge,
|
||||
run_manager,
|
||||
record,
|
||||
ctx=RunContext(checkpointer=checkpointer, checkpoint_channel_mode="delta"),
|
||||
ctx=RunContext(checkpointer=checkpointer, thread_store=thread_store, checkpoint_channel_mode="delta"),
|
||||
agent_factory=agent_factory,
|
||||
graph_input={"messages": [HumanMessage(content="q2", id="h2")]},
|
||||
graph_input={
|
||||
"messages": [HumanMessage(content="q2", id="h2")],
|
||||
"title": "User renamed title",
|
||||
},
|
||||
config=_run_config("worker-branch", base.config["configurable"]["checkpoint_id"]),
|
||||
stream_modes=["values"],
|
||||
),
|
||||
@ -273,6 +287,8 @@ async def test_run_agent_streams_from_the_linearized_delta_resume(tmp_path):
|
||||
final_accessor = CheckpointStateAccessor.bind(created_graphs[-1], checkpointer, mode="delta")
|
||||
final = await final_accessor.aget(_run_config("worker-branch"))
|
||||
assert _ids(final) == ["h1", "a1", "h2", "a2-new"]
|
||||
assert final.values["title"] == "User renamed title"
|
||||
thread_store.update_display_name.assert_awaited_once_with("worker-branch", "User renamed title")
|
||||
|
||||
|
||||
async def test_run_agent_serializes_resume_preparation_with_checkpoint_writes(monkeypatch):
|
||||
|
||||
@ -390,12 +390,46 @@ def test_prepare_regenerate_payload_returns_clean_input_and_base_checkpoint():
|
||||
"regenerate_from_run_id": "run-old",
|
||||
"regenerate_checkpoint_id": "ckpt-base",
|
||||
}
|
||||
assert "title" not in response.input
|
||||
regenerated_human = response.input["messages"][0]
|
||||
assert regenerated_human["id"] == "human-1"
|
||||
assert regenerated_human["content"] == [{"type": "text", "text": "/data-analysis analyze data.csv"}]
|
||||
assert regenerated_human["additional_kwargs"] == {"files": [{"filename": "data.csv", "path": "/mnt/user-data/uploads/data.csv"}]}
|
||||
|
||||
|
||||
def test_prepare_regenerate_payload_preserves_latest_thread_title():
|
||||
from app.gateway.routers import thread_runs
|
||||
|
||||
human = HumanMessage(id="human-1", content="question")
|
||||
ai = AIMessage(id="ai-1", content="answer v1")
|
||||
base = _checkpoint("ckpt-base", [])
|
||||
latest = _checkpoint("ckpt-ai", [human, ai])
|
||||
latest.checkpoint["channel_values"]["title"] = "User renamed title"
|
||||
checkpointer = FakeCheckpointer([latest, base])
|
||||
event_store = FakeEventStore(
|
||||
[
|
||||
{
|
||||
"run_id": "run-old",
|
||||
"event_type": "llm.ai.response",
|
||||
"category": "message",
|
||||
"content": {"id": "ai-1", "type": "ai", "content": "answer v1"},
|
||||
"metadata": {"caller": "lead_agent"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
thread_runs._prepare_regenerate_payload(
|
||||
"thread-1",
|
||||
"ai-1",
|
||||
_request(checkpointer, event_store),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.checkpoint["checkpoint_id"] == "ckpt-base"
|
||||
assert response.input["title"] == "User renamed title"
|
||||
|
||||
|
||||
def test_prepare_regenerate_payload_does_not_mutate_legacy_single_checkpoint_branch():
|
||||
from app.gateway.routers.thread_runs import _prepare_regenerate_payload
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user