fix(deer-flow): retry failed agent setup saves (#6628)

* fix(deer-flow): retry failed agent setup saves

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

* fix(frontend): fence superseded agent save callbacks

---------

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
PeaceMaker-best 2026-10-19 10:55:50 +08:00 committed by GitHub
parent 92e9afe6ea
commit dbebd18dd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 497 additions and 17 deletions

View File

@ -2247,6 +2247,12 @@ the stable English `name`. API callers can pass `display_name` to agent creation
or update requests; an omitted update preserves it and `null` clears it. The
same optional field is supported in the agent's `config.yaml`.
The new-Agent page confirms creation only after the latest `setup_agent` call
succeeds and the persisted Agent can be read back. A tool failure or an
unconfirmed write keeps the draft visible and restores **Save agent** so the
operation can be retried instead of reporting a false success. Read-back results
from a superseded Save attempt cannot change a newer attempt or its feedback.
Saving an unrelated Agent setting preserves its existing thinking and reasoning
defaults, including while the model catalog is loading, unavailable, or missing
the configured model. Explicit reasoning edits are saved independently; choosing

View File

@ -156,6 +156,10 @@ previewing the proposed local name; the Gateway is authoritative for schema,
name, model, and conflict validation. Export downloads must never synthesize
runtime state from browser caches.
New-Agent saves need the latest `setup_agent` success and API read-back.
Failed/exhausted saves restore Save. Fence superseded or unmounted callbacks.
DOM regression: `new-agent-save.dom.test.tsx`.
- **Imports**: Enforced ordering (builtin → external → internal → parent → sibling), alphabetized, newlines between groups. Use inline type imports: `import { type Foo }`.
- **Unused variables**: Prefix with `_`.
- **Class names**: Use `cn()` from `@/lib/utils` for conditional Tailwind classes.

View File

@ -116,6 +116,8 @@ Tests: `html-assets.dom.test.ts`, `artifact-file-preview.dom.test.tsx`,
prompt/error, never permission to overwrite the existing Agent.
Import errors join FastAPI 422 validation messages into readable text while
preserving string conflict details; unusable error bodies use a status fallback.
New-Agent saves require the latest `setup_agent` success and read-back.
ToolMessage/`deerflow_tool_meta.status` errors or exhausted retries restore Save.
6. Components subscribe to thread state and render updates
AI message grouping uses `extractContentFromMessage()` to identify visible answer content. A non-empty content array may contain only Anthropic thinking blocks; keep it in `assistant:processing` until answer content arrives. Cover both streamed snapshots in `tests/unit/core/messages/utils.test.ts`.

View File

@ -9,7 +9,7 @@ import {
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
@ -44,7 +44,7 @@ import {
type HumanInputResponse,
} from "@/core/messages/human-input";
import { safeLocalStorage } from "@/core/settings/local";
import { hasToolResult, useThreadStream } from "@/core/threads/hooks";
import { getToolResultStatus, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
@ -90,6 +90,15 @@ export default function NewAgentPage() {
const [setupAgentStatus, setSetupAgentStatus] =
useState<SetupAgentStatus>("idle");
// Each Save or later setup result supersedes earlier read-back callbacks.
const saveAttempt = useRef(0);
useEffect(
() => () => {
saveAttempt.current += 1;
},
[],
);
const threadId = useMemo(() => uuid(), []);
const { thread, sendMessage } = useThreadStream({
@ -102,17 +111,28 @@ export default function NewAgentPage() {
if (agent || setupAgentStatus !== "requested") {
return;
}
if (!agentName || !hasToolResult(state.messages, "setup_agent")) {
const attempt = ++saveAttempt.current;
const resultStatus = getToolResultStatus(state.messages, "setup_agent");
if (!agentName || resultStatus === "pending") {
setSetupAgentStatus("idle");
return;
}
setSetupAgentStatus("completed");
if (resultStatus === "error") {
setSetupAgentStatus("idle");
toast.error(t.agents.agentSaveFailed);
return;
}
void getAgentWithRetry(agentName).then((fetched) => {
if (saveAttempt.current !== attempt) {
return;
}
if (fetched) {
setSetupAgentStatus("completed");
setAgent(fetched);
return;
}
setSetupAgentStatus("idle");
toast.error(t.agents.agentCreatedPendingRefresh);
});
},
@ -263,6 +283,7 @@ export default function NewAgentPage() {
return;
}
const attempt = ++saveAttempt.current;
setSetupAgentStatus("requested");
setShowSaveHint(false);
try {
@ -272,8 +293,14 @@ export default function NewAgentPage() {
{ agent_name: agentName },
{ additionalKwargs: { hide_from_ui: true } },
);
toast.success(t.agents.saveRequested);
if (saveAttempt.current === attempt) {
toast.success(t.agents.saveRequested);
}
} catch (error) {
if (saveAttempt.current !== attempt) {
return;
}
saveAttempt.current += 1;
setSetupAgentStatus("idle");
toast.error(error instanceof Error ? error.message : String(error));
}

View File

@ -1410,8 +1410,10 @@ export const enUS: Translations = {
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
saveCommandMessage:
"Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.",
agentSaveFailed:
"DeerFlow could not save this agent. Review the tool error and try again.",
agentCreatedPendingRefresh:
"The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.",
"DeerFlow could not confirm the saved agent. Please try saving it again.",
more: "More actions",
agentCreated: "Agent created!",
startChatting: "Start chatting",

View File

@ -1147,6 +1147,7 @@ export interface Translations {
saveRequested: string;
saveHint: string;
saveCommandMessage: string;
agentSaveFailed: string;
agentCreatedPendingRefresh: string;
more: string;
agentCreated: string;

View File

@ -1321,8 +1321,9 @@ export const zhCN: Translations = {
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
saveCommandMessage:
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
agentSaveFailed: "DeerFlow 未能保存这个智能体。请查看工具错误后重试。",
agentCreatedPendingRefresh:
"智能体已创建,但 DeerFlow 暂时还无法读取到它。请稍后刷新当前页面。",
"DeerFlow 未能确认智能体已保存,请重新尝试保存。",
more: "更多操作",
agentCreated: "智能体已创建!",
startChatting: "开始对话",

View File

@ -154,25 +154,58 @@ export type PendingPreparedReplayMask = {
replacementHumanMessageId?: string;
};
export function hasToolResult(messages: Message[], toolName: string): boolean {
const matchingToolCallIds = new Set<string>();
export type ToolResultStatus = "pending" | "success" | "error";
function isErrorToolMessage(message: Message): boolean {
if (message.type !== "tool") {
return false;
}
if (message.status === "error") {
return true;
}
const metadata = message.additional_kwargs?.deerflow_tool_meta;
return (
typeof metadata === "object" &&
metadata !== null &&
"status" in metadata &&
metadata.status === "error"
);
}
export function getToolResultStatus(
messages: Message[],
toolName: string,
): ToolResultStatus {
let latestToolCallId: string | undefined;
for (const message of messages) {
if (message.type !== "ai") {
continue;
}
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name === toolName && toolCall.id) {
matchingToolCallIds.add(toolCall.id);
latestToolCallId = toolCall.id;
}
}
}
return messages.some(
(message) =>
message.type === "tool" &&
(message.name === toolName ||
matchingToolCallIds.has(message.tool_call_id)),
);
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.type !== "tool") {
continue;
}
const matches = latestToolCallId
? message.tool_call_id === latestToolCallId
: message.name === toolName;
if (matches) {
return isErrorToolMessage(message) ? "error" : "success";
}
}
return "pending";
}
export function hasToolResult(messages: Message[], toolName: string): boolean {
return getToolResultStatus(messages, toolName) !== "pending";
}
/**

View File

@ -0,0 +1,341 @@
import type { Message } from "@langchain/langgraph-sdk";
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import type { ButtonHTMLAttributes, PropsWithChildren } from "react";
import NewAgentPage from "@/app/workspace/agents/new/page";
import type { Agent } from "@/core/agents";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
const mocks = rs.hoisted(() => ({
getAgent: rs.fn(),
checkAgentName: rs.fn(),
sendMessage: rs.fn(),
toastError: rs.fn(),
toastSuccess: rs.fn(),
onFinish: undefined as ((state: { messages: Message[] }) => void) | undefined,
}));
rs.mock("next/navigation", () => ({ useRouter: () => ({ push: rs.fn() }) }));
rs.mock("sonner", () => ({
toast: { error: mocks.toastError, success: mocks.toastSuccess },
}));
rs.mock("@/core/agents/api", () => ({
...rs.requireActual("@/core/agents/api"),
getAgent: mocks.getAgent,
checkAgentName: mocks.checkAgentName,
}));
rs.mock("@/core/threads/hooks", () => ({
...rs.requireActual("@/core/threads/hooks"),
useThreadStream: (options: { onFinish: typeof mocks.onFinish }) => {
mocks.onFinish = options.onFinish;
return {
thread: { isLoading: false, messages: [], values: {} },
sendMessage: mocks.sendMessage,
};
},
}));
rs.mock("@/components/workspace/artifacts", () => ({
ArtifactsProvider: ({ children }: PropsWithChildren) => <>{children}</>,
}));
rs.mock("@/components/workspace/messages", () => ({ MessageList: () => null }));
// Keep Save interactions accessible without depending on menu positioning.
rs.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: PropsWithChildren) => <>{children}</>,
DropdownMenuContent: ({ children }: PropsWithChildren) => <>{children}</>,
DropdownMenuTrigger: ({ children }: PropsWithChildren) => <>{children}</>,
DropdownMenuItem: ({
children,
disabled,
onSelect,
}: PropsWithChildren<{
disabled?: boolean;
onSelect: () => void;
}>) => (
<button disabled={disabled} onClick={onSelect}>
{children}
</button>
),
}));
rs.mock("@/components/ai-elements/prompt-input", () => ({
PromptInput: ({
children,
onSubmit,
}: PropsWithChildren<{ onSubmit: (input: { text: string }) => void }>) => (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit({ text: "Please adjust the agent" });
}}
>
{children}
</form>
),
PromptInputTextarea: () => <textarea aria-label="Agent setup chat" />,
PromptInputFooter: ({ children }: PropsWithChildren) => <>{children}</>,
PromptInputSubmit: (props: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props} type="submit">
Send
</button>
),
}));
const savedAgent: Agent = {
name: "researcher",
description: "Research assistant",
model: null,
tool_groups: null,
skills: null,
};
let messages: Message[];
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function openSetup() {
const view = render(
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<NewAgentPage />
</I18nContext.Provider>,
);
fireEvent.change(
screen.getByPlaceholderText(enUS.agents.nameStepPlaceholder),
{
target: { value: savedAgent.name },
},
);
await act(async () => {
fireEvent.click(
screen.getByRole("button", { name: enUS.agents.nameStepContinue }),
);
});
return view;
}
async function save() {
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: enUS.agents.save }));
});
}
async function finish(status: "success" | "error" | "pending") {
const id = `setup-${messages.length}`;
messages.push({
type: "ai",
content: "",
tool_calls: [{ id, name: "setup_agent", args: {} }],
} as Message);
if (status !== "pending") {
messages.push({
type: "tool",
content: status,
name: "setup_agent",
tool_call_id: id,
status,
} as Message);
}
await act(async () => {
mocks.onFinish!({ messages: [...messages] });
});
}
async function subsequentSetupError() {
await act(async () => {
fireEvent.submit(
screen
.getByRole("textbox", { name: "Agent setup chat" })
.closest("form")!,
);
});
await finish("error");
}
async function exhaustRead(read: ReturnType<typeof deferred<Agent>>) {
await act(async () => {
read.reject(new Error("not visible yet"));
});
await act(() => rs.advanceTimersByTimeAsync(4_000));
}
beforeEach(() => {
rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mocks.getAgent.mockReset().mockRejectedValue(new Error("not visible yet"));
mocks.checkAgentName.mockReset().mockResolvedValue({ available: true });
mocks.sendMessage.mockReset().mockResolvedValue(undefined);
mocks.toastError.mockReset();
mocks.toastSuccess.mockReset();
mocks.onFinish = undefined;
messages = [];
});
afterEach(() => {
cleanup();
rs.useRealTimers();
});
describe("New-Agent Save read-back ownership", () => {
it.each(["missing", "found"] as const)(
"ignores an older %s read-back while a newer Save is requested",
async (outcome) => {
const oldRead = deferred<Agent>();
mocks.getAgent.mockReturnValueOnce(oldRead.promise);
await openSetup();
await save();
await finish("success");
expect(mocks.getAgent).toHaveBeenCalledTimes(1);
await subsequentSetupError();
await save();
mocks.toastError.mockClear();
if (outcome === "found") {
await act(async () => {
oldRead.resolve(savedAgent);
});
} else {
await exhaustRead(oldRead);
}
expect(screen.queryByText(enUS.agents.agentCreated)).toBeNull();
expect(
screen
.getByRole("button", { name: enUS.agents.saving })
.hasAttribute("disabled"),
).toBe(true);
expect(mocks.toastError).not.toHaveBeenCalled();
const readsBeforeLatestFinish = mocks.getAgent.mock.calls.length;
mocks.getAgent.mockResolvedValueOnce(savedAgent);
await finish("success");
expect(mocks.getAgent).toHaveBeenCalledTimes(readsBeforeLatestFinish + 1);
expect(screen.getByText(enUS.agents.agentCreated)).toBeTruthy();
},
);
it("does not confirm an old read after a newer setup error restores Save", async () => {
const oldRead = deferred<Agent>();
mocks.getAgent.mockReturnValueOnce(oldRead.promise);
await openSetup();
await save();
await finish("success");
await subsequentSetupError();
await act(async () => {
oldRead.resolve(savedAgent);
});
expect(screen.queryByText(enUS.agents.agentCreated)).toBeNull();
expect(
screen
.getByRole("button", { name: enUS.agents.save })
.hasAttribute("disabled"),
).toBe(false);
});
it("ignores an older read failure after a newer read confirms creation", async () => {
const oldRead = deferred<Agent>();
mocks.getAgent.mockReturnValueOnce(oldRead.promise);
await openSetup();
await save();
await finish("success");
// Another chat completion starts a second read while Save is still requested.
mocks.getAgent.mockResolvedValueOnce(savedAgent);
await finish("success");
expect(screen.getByText(enUS.agents.agentCreated)).toBeTruthy();
mocks.toastError.mockClear();
await exhaustRead(oldRead);
expect(screen.getByText(enUS.agents.agentCreated)).toBeTruthy();
expect(mocks.toastError).not.toHaveBeenCalled();
});
it("ignores read-back exhaustion after the page unmounts", async () => {
const oldRead = deferred<Agent>();
mocks.getAgent.mockReturnValueOnce(oldRead.promise);
const view = await openSetup();
await save();
await finish("success");
view.unmount();
await exhaustRead(oldRead);
expect(mocks.toastError).not.toHaveBeenCalled();
});
it("restores Save after current read-back exhaustion and confirms a retry", async () => {
await openSetup();
await save();
await finish("success");
await act(() => rs.advanceTimersByTimeAsync(4_000));
expect(mocks.getAgent).toHaveBeenCalledTimes(5);
expect(mocks.toastError).toHaveBeenCalledWith(
enUS.agents.agentCreatedPendingRefresh,
);
expect(
screen
.getByRole("button", { name: enUS.agents.save })
.hasAttribute("disabled"),
).toBe(false);
mocks.getAgent.mockResolvedValueOnce(savedAgent);
await save();
await finish("success");
expect(screen.getByText(enUS.agents.agentCreated)).toBeTruthy();
});
it.each(["rejected", "resolved"] as const)(
"ignores a superseded Save send that later %s",
async (outcome) => {
const oldSend = deferred<void>();
const oldRead = deferred<Agent>();
await openSetup();
mocks.sendMessage.mockReturnValueOnce(oldSend.promise);
mocks.getAgent.mockReturnValueOnce(oldRead.promise);
await save();
await finish("success");
await subsequentSetupError();
await save();
mocks.toastError.mockClear();
mocks.toastSuccess.mockClear();
await act(async () => {
if (outcome === "rejected")
oldSend.reject(new Error("old send failed"));
else oldSend.resolve();
});
expect(
screen
.getByRole("button", { name: enUS.agents.saving })
.hasAttribute("disabled"),
).toBe(true);
expect(mocks.toastError).not.toHaveBeenCalled();
expect(mocks.toastSuccess).not.toHaveBeenCalled();
mocks.getAgent.mockResolvedValueOnce(savedAgent);
await finish("success");
expect(screen.getByText(enUS.agents.agentCreated)).toBeTruthy();
await act(async () => {
oldRead.resolve(savedAgent);
});
},
);
it("restores Save and reports a current send failure", async () => {
await openSetup();
mocks.sendMessage.mockRejectedValueOnce(new Error("save send failed"));
await save();
expect(mocks.toastError).toHaveBeenCalledWith("save send failed");
expect(
screen
.getByRole("button", { name: enUS.agents.save })
.hasAttribute("disabled"),
).toBe(false);
expect(mocks.getAgent).not.toHaveBeenCalled();
});
});

View File

@ -1,7 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import { expect, test } from "@rstest/core";
import { hasToolResult } from "@/core/threads/hooks";
import { getToolResultStatus, hasToolResult } from "@/core/threads/hooks";
test("recognizes a completed tool from its ToolMessage", () => {
const messages = [
@ -58,3 +58,66 @@ test("matches a ToolMessage without a name through its tool call id", () => {
expect(hasToolResult(messages, "setup_agent")).toBe(true);
});
test("reports a failed tool result instead of treating it as a successful save", () => {
const messages = [
{
type: "ai",
content: "",
tool_calls: [{ id: "call-1", name: "setup_agent", args: {} }],
},
{
type: "tool",
content: "Error: unable to save agent",
name: "setup_agent",
tool_call_id: "call-1",
status: "error",
},
] as Message[];
expect(getToolResultStatus(messages, "setup_agent")).toBe("error");
});
test("honors structured DeerFlow error metadata", () => {
const messages = [
{
type: "ai",
content: "",
tool_calls: [{ id: "call-1", name: "setup_agent", args: {} }],
},
{
type: "tool",
content: "Agent storage is unavailable",
name: "setup_agent",
tool_call_id: "call-1",
additional_kwargs: {
deerflow_tool_meta: { status: "error" },
},
},
] as Message[];
expect(getToolResultStatus(messages, "setup_agent")).toBe("error");
});
test("waits for the latest setup attempt instead of reusing an older success", () => {
const messages = [
{
type: "ai",
content: "",
tool_calls: [{ id: "call-1", name: "setup_agent", args: {} }],
},
{
type: "tool",
content: "Agent saved",
name: "setup_agent",
tool_call_id: "call-1",
},
{
type: "ai",
content: "",
tool_calls: [{ id: "call-2", name: "setup_agent", args: {} }],
},
] as Message[];
expect(getToolResultStatus(messages, "setup_agent")).toBe("pending");
});