fix: 协助卡停轮与提交防 409,隐藏配置向导会话,并限制岗位会商产物重复写入
澄清中间件改为 ToolMessage + after_model 停轮,前端提交前取消残留 run;系统/向导 thread_type 从对话列表排除;岗位总结席位加 write_file 上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
51afebb394
commit
b81d200eca
31 files changed
+1459
-592
No files matched your search
@@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
sortThreadsPinnedFirst,
|
||||
@@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
|
||||
// (带机器人图标、点击回创建页);置顶优先。
|
||||
// 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致;
|
||||
// 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
[threads],
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
pageSize: RECENT_LIMIT,
|
||||
metadata: { agent_id: agentId },
|
||||
});
|
||||
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
|
||||
// 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。
|
||||
const threads = (data?.threads ?? []).filter(
|
||||
(th) =>
|
||||
th.metadata?.thread_type !== "bootstrap" &&
|
||||
th.metadata?.thread_type !== "scheduler" &&
|
||||
th.metadata?.thread_type !== "roundtable",
|
||||
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
@@ -226,14 +227,8 @@ export function GeneralQaSidebar() {
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
|
||||
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
[threads],
|
||||
);
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
@@ -193,13 +194,9 @@ export function RecentChatList({
|
||||
);
|
||||
|
||||
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
|
||||
const visibleThreads = sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
|
||||
@@ -99,8 +99,14 @@ export function useNotebookChat(
|
||||
},
|
||||
});
|
||||
|
||||
const send = async (text: string, selectedSourceIds: number[]) => {
|
||||
if (thread.isLoading || !token) return;
|
||||
const send = async (
|
||||
text: string,
|
||||
selectedSourceIds: number[],
|
||||
options?: { fromClarification?: boolean },
|
||||
) => {
|
||||
if (!token) return;
|
||||
// 协助卡提交前会先停轮;此时 isLoading 可能仍为 true,不能直接丢弃。
|
||||
if (thread.isLoading && !options?.fromClarification) return;
|
||||
// API 需要 document.id,而不是 source.id
|
||||
const toDocIds = (ids: number[]) =>
|
||||
ids
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
isAbortLikeError,
|
||||
isTransientStreamTransportError,
|
||||
} from "./thread-access-error";
|
||||
import { isHiddenFromRecentChats, shouldHideSystemThreadsFromList } from "./utils";
|
||||
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
@@ -210,6 +211,39 @@ export async function stopThreadRun(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run
|
||||
* 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。
|
||||
*/
|
||||
export async function stopActiveThreadBeforeClarificationReply(
|
||||
threadId: string | null | undefined,
|
||||
thread: { isLoading?: boolean; stop: () => void | Promise<void> },
|
||||
): Promise<void> {
|
||||
let storedRunId: string | null = null;
|
||||
if (threadId && typeof window !== "undefined") {
|
||||
try {
|
||||
storedRunId = normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
);
|
||||
} catch {
|
||||
storedRunId = null;
|
||||
}
|
||||
}
|
||||
if (!thread.isLoading && !storedRunId) return;
|
||||
try {
|
||||
await stopThreadRun(threadId);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(thread.stop());
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
// cancel 落地需要一拍,立刻 submit 仍可能 409。
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
@@ -1451,18 +1485,10 @@ export function useThreads(
|
||||
}
|
||||
|
||||
// Safety net on top of the backend's system-thread exclusion: drop
|
||||
// machine-created threads (scheduled tasks, roundtable workers,
|
||||
// notebooks) in case an older backend still returns them.
|
||||
return threads.filter((t) => {
|
||||
const metadata = t.metadata as Record<string, unknown> | null;
|
||||
if (metadata?.system) return false;
|
||||
const threadType = metadata?.thread_type;
|
||||
return (
|
||||
threadType !== "notebook" &&
|
||||
threadType !== "scheduler" &&
|
||||
threadType !== "roundtable"
|
||||
);
|
||||
});
|
||||
// machine-created / wizard threads (scheduled tasks, report-structure
|
||||
// setup, writing setup, notebooks, …) in case an older backend still
|
||||
// returns them.
|
||||
return threads.filter((t) => !isHiddenFromRecentChats(t));
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
@@ -1545,8 +1571,13 @@ export function useThreadsPage(options: {
|
||||
throw new Error("Failed to load conversations");
|
||||
}
|
||||
const rows = (await response.json()) as AgentThread[];
|
||||
// 与后端 exclude_system 对齐:普通列表再兜一层。调用方显式按
|
||||
// thread_type / system 取数(笔记本等)时不滤,避免把目标会话藏掉。
|
||||
const visible = shouldHideSystemThreadsFromList(metadata)
|
||||
? rows.filter((thread) => !isHiddenFromRecentChats(thread))
|
||||
: rows;
|
||||
return {
|
||||
threads: rows.slice(0, pageSize),
|
||||
threads: visible.slice(0, pageSize),
|
||||
hasMore: rows.length > pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -91,6 +91,44 @@ export function isBootstrapThread(thread: {
|
||||
return thread.metadata?.thread_type === "bootstrap";
|
||||
}
|
||||
|
||||
/**
|
||||
* 不应出现在普通对话列表里的 thread_type。
|
||||
* 与后端 ``EXCLUDED_SYSTEM_THREAD_TYPES`` 保持同步;bootstrap 不在此列(侧栏仍展示)。
|
||||
*/
|
||||
export const HIDDEN_FROM_RECENT_CHAT_TYPES = new Set([
|
||||
"scheduler",
|
||||
"roundtable",
|
||||
"notebook",
|
||||
"deep_research",
|
||||
"writing_setup",
|
||||
"report_structure_setup",
|
||||
]);
|
||||
|
||||
export function isSetupWizardThread(thread: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): boolean {
|
||||
const threadType = thread.metadata?.thread_type;
|
||||
return threadType === "writing_setup" || threadType === "report_structure_setup";
|
||||
}
|
||||
|
||||
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
|
||||
export function isHiddenFromRecentChats(thread: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): boolean {
|
||||
const metadata = thread.metadata;
|
||||
if (metadata?.system === true) return true;
|
||||
const threadType = metadata?.thread_type;
|
||||
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
|
||||
}
|
||||
|
||||
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
|
||||
export function shouldHideSystemThreadsFromList(
|
||||
metadata?: Record<string, unknown> | null,
|
||||
): boolean {
|
||||
if (!metadata) return true;
|
||||
return !("thread_type" in metadata || "system" in metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在
|
||||
* /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ThreadContext } from '@/components/workspace/messages/context';
|
||||
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils';
|
||||
import { getAPIClient } from '@/core/api';
|
||||
import { useModels } from '@/core/models/hooks';
|
||||
import { useThreadStream } from '@/core/threads/hooks';
|
||||
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from '@/core/threads/hooks';
|
||||
import { uuid } from '@/core/utils/uuid';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -146,7 +146,10 @@ export function WritingSetupChat({
|
||||
|
||||
const ensureThreadExists = useCallback(async () => {
|
||||
if (threadPrepared) return;
|
||||
await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } });
|
||||
await getAPIClient().threads.create({
|
||||
threadId,
|
||||
metadata: { thread_type: 'writing_setup', system: true },
|
||||
});
|
||||
setThreadPrepared(true);
|
||||
}, [threadId, threadPrepared]);
|
||||
|
||||
@@ -167,15 +170,9 @@ export function WritingSetupChat({
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
if (thread.isLoading) {
|
||||
try {
|
||||
await Promise.resolve(thread.stop());
|
||||
} catch {
|
||||
/* still send the answer */
|
||||
}
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, { text: trimmed, files: [] }, buildWritingSampleContext(formCtx));
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
@@ -177,9 +178,10 @@ export function CanvasChatPanel() {
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await sendMessage(answer, { mode });
|
||||
},
|
||||
[sendMessage, mode],
|
||||
[sendMessage, mode, thread, threadId],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
|
||||
@@ -31,7 +31,7 @@ import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-dep
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import {
|
||||
@@ -542,11 +542,12 @@ export default function AgentChatPage({
|
||||
}, [taskAutoStart, taskStartText, isNewThread, handleSubmit]);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
async (answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
void sendMessage(threadId, { text: answer, files: [] }, { agent_id });
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
|
||||
},
|
||||
[sendMessage, threadId, agent_id],
|
||||
[sendMessage, thread, threadId, agent_id],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
useKnowledgeRagEnabled,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
|
||||
@@ -525,11 +525,12 @@ export default function ChatPage() {
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
async (answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
void sendMessage(threadId, { text: answer, files: [] });
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
[sendMessage, thread, threadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { setStoredAuth } from "@/core/auth";
|
||||
import { resolveEmbedChatParams } from "@/core/embed/params";
|
||||
import { registerEmbedSession } from "@/core/embed/status-api";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { ChatBox } from "@/components/workspace/chats";
|
||||
import {
|
||||
@@ -110,10 +110,11 @@ export default function EmbedChatPage() {
|
||||
}, [authReady, threadId, registerSessionForThread]);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
void sendMessage(threadId, { text: answer, files: [] });
|
||||
async (answer: string) => {
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
[sendMessage, thread, threadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -336,11 +336,12 @@ export default function IframeChatPage() {
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
async (answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
void sendMessage(threadId, { text: answer, files: [] });
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
[sendMessage, thread, threadId],
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
|
||||
@@ -53,7 +53,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { isAgentSkillCandidate } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -335,13 +335,25 @@ export default function NewAgentPage() {
|
||||
// Chat is purely a discussion channel. The agent record is only
|
||||
// created when the user explicitly clicks Save (top-right). If the
|
||||
// model proposes a config via setup_agent, our approval handler
|
||||
// prefills the form locally 鈥?it does NOT persist anything.
|
||||
// prefills the form locally — it does NOT persist anything.
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||
},
|
||||
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
// 协助卡提交不能因 isLoading 直接丢弃——弱模型出卡后可能仍在流式。
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||
},
|
||||
[ensureThreadExists, sendMessage, thread, threadId],
|
||||
);
|
||||
|
||||
const handleApproveSetupAgent = useCallback(
|
||||
async (payload: SetupAgentApprovalPayload) => {
|
||||
// Approving the model's setup_agent proposal **does not** persist
|
||||
@@ -425,7 +437,7 @@ export default function NewAgentPage() {
|
||||
className="size-full"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
onClarificationSubmit={handleChatSubmit}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onSetupAgentApprove={handleApproveSetupAgent}
|
||||
onSetupAgentReject={handleRejectSetupAgent}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||
import { fetchDocumentByChunk } from "@/core/studio/api";
|
||||
import type { DocumentWithChunks } from "@/core/studio/api";
|
||||
import type { SourceItem } from "@/core/studio/types";
|
||||
@@ -381,12 +382,13 @@ export default function StudioNotebookDetailPage({
|
||||
// Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 +
|
||||
// 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路,
|
||||
// 把当前选中的来源也一并带上,保持上下文一致。
|
||||
// 弱模型出卡后可能仍在流式:先 stop 再发,不能因 isStreaming 直接丢弃。
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
if (isStreaming) return;
|
||||
await send(answer, selectedSourceIds);
|
||||
await stopActiveThreadBeforeClarificationReply(chatThreadId, thread);
|
||||
await send(answer, selectedSourceIds, { fromClarification: true });
|
||||
},
|
||||
[send, selectedSourceIds, isStreaming],
|
||||
[send, selectedSourceIds, chatThreadId, thread],
|
||||
);
|
||||
|
||||
const handleBackToList = () => {
|
||||
|
||||
@@ -87,7 +87,11 @@ import { useLocalSettings } from "@/core/settings/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { DocumentRewriteStreamEvent, DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
|
||||
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
|
||||
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||
import {
|
||||
stopActiveThreadBeforeClarificationReply,
|
||||
stopThreadRun,
|
||||
useThreadStream,
|
||||
} from "@/core/threads/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -947,9 +951,11 @@ function DeepResearchWorkbenchInner() {
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
const threadId = session?.runtimeThreadId;
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, collectorThread);
|
||||
await sendCollectorMessage(text);
|
||||
},
|
||||
[sendCollectorMessage],
|
||||
[collectorThread, sendCollectorMessage, session?.runtimeThreadId],
|
||||
);
|
||||
|
||||
// The very first research topic: auto-sent into the freshly pre-built
|
||||
|
||||
@@ -28,7 +28,7 @@ import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolC
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -79,7 +79,10 @@ export function ReportStructureSetupChat({
|
||||
|
||||
const ensureThreadExists = useCallback(async () => {
|
||||
if (threadPrepared) return;
|
||||
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "report_structure_setup" } });
|
||||
await getAPIClient().threads.create({
|
||||
threadId,
|
||||
metadata: { thread_type: "report_structure_setup", system: true },
|
||||
});
|
||||
setThreadPrepared(true);
|
||||
}, [threadId, threadPrepared]);
|
||||
|
||||
@@ -99,15 +102,9 @@ export function ReportStructureSetupChat({
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
if (thread.isLoading) {
|
||||
try {
|
||||
await Promise.resolve(thread.stop());
|
||||
} catch {
|
||||
/* best-effort: still send the answer */
|
||||
}
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@ import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
@@ -460,7 +460,10 @@ export function PositionBuiltinAgentChat({
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
async (
|
||||
message: PromptInputMessage,
|
||||
options?: { fromClarification?: boolean },
|
||||
) => {
|
||||
if (!session) {
|
||||
toast.warning("请先启动业务链");
|
||||
return;
|
||||
@@ -469,7 +472,9 @@ export function PositionBuiltinAgentChat({
|
||||
toast.info(readOnlyHint || "当前阶段暂不可对话");
|
||||
return;
|
||||
}
|
||||
if (pendingSubmissionRef.current || thread.isLoading) {
|
||||
if (options?.fromClarification) {
|
||||
pendingSubmissionRef.current = false;
|
||||
} else if (pendingSubmissionRef.current || thread.isLoading) {
|
||||
return;
|
||||
}
|
||||
pendingSubmissionRef.current = true;
|
||||
@@ -540,6 +545,8 @@ export function PositionBuiltinAgentChat({
|
||||
{
|
||||
agent_id: submission.agentId,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
// Summary: one Markdown only. Action-plan needs report + JSON — no cap.
|
||||
...(kind === "summary" ? { position_artifact_cap: 1 } : {}),
|
||||
},
|
||||
{
|
||||
hiddenContext: {
|
||||
@@ -683,9 +690,18 @@ export function PositionBuiltinAgentChat({
|
||||
// Use the built-in agent's regular submit path. This keeps a
|
||||
// clarification answer on the same persisted thread and retains
|
||||
// the summary/action-plan preparation and completion lifecycle.
|
||||
onClarificationSubmit={(answer) =>
|
||||
handleSubmit({ text: answer, files: [] })
|
||||
}
|
||||
onClarificationSubmit={(answer) => {
|
||||
void (async () => {
|
||||
await stopActiveThreadBeforeClarificationReply(
|
||||
boundThreadId ?? displayThreadId,
|
||||
thread,
|
||||
);
|
||||
await handleSubmit(
|
||||
{ text: answer, files: [] },
|
||||
{ fromClarification: true },
|
||||
);
|
||||
})();
|
||||
}}
|
||||
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
||||
onOpenArtifact={handleOpenMessageArtifact}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MessageList } from "@/components/workspace/messages";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
stopActiveThreadBeforeClarificationReply,
|
||||
useEditMessage,
|
||||
useThreadStream,
|
||||
type ToolStartEvent,
|
||||
@@ -852,6 +853,9 @@ export function PositionNodeChat({
|
||||
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
|
||||
agent_id: node.agentId,
|
||||
task_external_context: taskExternalContext,
|
||||
// Hard-cap outputs write_file to one deliverable per turn (weak models
|
||||
// otherwise rewrite the same Markdown and loop). Action-plan is exempt.
|
||||
position_artifact_cap: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
autoPreviewRunArmedRef.current = false;
|
||||
@@ -1006,9 +1010,15 @@ export function PositionNodeChat({
|
||||
// nodes own the actual stream. Route their answer through the
|
||||
// same submit path as the normal input so handoff context,
|
||||
// permissions and node lifecycle updates are all preserved.
|
||||
onClarificationSubmit={(answer) =>
|
||||
handleSubmit({ text: answer, files: [] })
|
||||
}
|
||||
onClarificationSubmit={(answer) => {
|
||||
void (async () => {
|
||||
await stopActiveThreadBeforeClarificationReply(
|
||||
boundThreadId ?? displayThreadId,
|
||||
thread,
|
||||
);
|
||||
await handleSubmit({ text: answer, files: [] });
|
||||
})();
|
||||
}}
|
||||
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
||||
onEditSave={handleSaveMessageEdit}
|
||||
messageContentOverrides={messageContentOverrides}
|
||||
|
||||
@@ -76,6 +76,26 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@layer utilities {
|
||||
.report-structure-detail-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.report-structure-detail-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.report-structure-detail-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.report-structure-detail-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
"Noto Sans S Chinese", "Noto Sans SC", "Source Han Sans SC", "思源黑体",
|
||||
|
||||
@@ -531,6 +531,16 @@ Built-in summary and action-plan completion also preserves a visible report
|
||||
answer when the model misses its final `write_file` call: the router writes
|
||||
that exact answer as the required Markdown artifact in the owning thread's
|
||||
`outputs/` directory, so users receive both chat text and a downloadable file.
|
||||
**Per-turn outputs write cap (弱模型防重复产物)**: ordinary position-node chats
|
||||
and the summary builtin send run-context `position_artifact_cap: 1` (whitelisted
|
||||
in `services.merge_run_context_overrides`). `PositionArtifactCapMiddleware`
|
||||
then keeps at most one successful `/mnt/user-data/outputs/` `write_file` per
|
||||
user turn — truncates excess tool calls in the same model response (and syncs
|
||||
raw provider `additional_kwargs.tool_calls`), rejects further writes after an
|
||||
`OK` or an earlier sibling in the same batch, ends the turn when only excess
|
||||
writes remain, and reminds the model to `present_files` / wrap up. **Action-plan
|
||||
does not send the flag** (it must write report `.md` +
|
||||
`action-plan-subtasks.json`). Tests: `tests/test_position_artifact_cap.py`.
|
||||
|
||||
### 岗位 (Position) Subscription System
|
||||
|
||||
|
||||
@@ -659,7 +659,8 @@ def _build_node_context_text(
|
||||
lines.extend(
|
||||
[
|
||||
"- 本席位最终只需交付一份 Markdown 报告文档。",
|
||||
"- 请在本轮或最终回复中创建 `/mnt/user-data/outputs/` 下的 `.md` 文件,并调用 `present_files` 呈现该文件。",
|
||||
"- 请在本轮用 `write_file` **只写一次** `/mnt/user-data/outputs/` 下的单个 `.md` 文件,然后调用 `present_files` 呈现该文件并简短收尾。",
|
||||
"- **禁止**在同一轮再调用 `write_file` 生成第二份(或内容相同的)产物;写完一份后立即结束文件写入。",
|
||||
"- 只有检测到该席位产出的 `.md` 文件后,系统才会把当前席位标记为已完成;仅有文字回答不会完成节点。",
|
||||
"- 报告内容应直接围绕当前岗位职责,吸收任务信息、任务意图与上游席位产物。",
|
||||
]
|
||||
@@ -1108,7 +1109,8 @@ async def _build_builtin_prompt(
|
||||
if kind == "summary":
|
||||
instructions = [
|
||||
"请基于以下岗位会商材料,生成或按用户要求修订《岗位会商方案总结报告》。",
|
||||
"必须使用 write_file 一次性写入 `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。",
|
||||
"必须使用 write_file **只写一次** `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。",
|
||||
"**禁止**再次 write_file 生成第二份(或相同)报告;写完后 present_files 并简短收尾即可。",
|
||||
"报告要覆盖任务目标、岗位关键结论、综合分析、方案建议、风险与执行路径;不要简单拼接原文。",
|
||||
]
|
||||
else:
|
||||
|
||||
@@ -703,11 +703,12 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
|
||||
from app.gateway.deps import get_thread_store
|
||||
|
||||
repo = get_thread_store(request)
|
||||
# System threads (scheduler runs, roundtable workers, …) carry
|
||||
# ``metadata.system=true`` and are machine-created — hide them from the
|
||||
# default conversation list so they don't crowd out real chats. A caller
|
||||
# that explicitly filters on ``thread_type``/``system`` (e.g. the studio
|
||||
# notebook list) opts out of the exclusion.
|
||||
# System threads (scheduler runs, roundtable workers, report-structure /
|
||||
# writing setup wizards, …) carry ``metadata.system=true`` and/or a known
|
||||
# ``thread_type`` — hide them from the default conversation list so they
|
||||
# don't crowd out real chats. A caller that explicitly filters on
|
||||
# ``thread_type``/``system`` (e.g. the studio notebook list) opts out of
|
||||
# the exclusion.
|
||||
explicit_system_filter = bool(body.metadata) and ("thread_type" in body.metadata or "system" in body.metadata)
|
||||
rows = await repo.search(
|
||||
metadata=body.metadata or None,
|
||||
|
||||
@@ -363,6 +363,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"task_external_context",
|
||||
# 岗位会商下游席位按需读取前序席位完整交付时使用的运行期数据。
|
||||
"roundtable_peer_deliveries",
|
||||
# 岗位会商席位 / 方案总结:每轮 outputs 下 write_file 上限(行动规划不传此键)。
|
||||
"position_artifact_cap",
|
||||
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
|
||||
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
|
||||
"summarization_enabled",
|
||||
|
||||
@@ -368,6 +368,13 @@ def _build_middlewares(
|
||||
|
||||
middlewares.append(SkillReviewMiddleware())
|
||||
|
||||
# PositionArtifactCapMiddleware — position-roundtable seats (and summary)
|
||||
# may only emit N outputs write_file calls per user turn. Action-plan skips
|
||||
# the context flag so it can still write report + JSON.
|
||||
from deerflow.agents.middlewares.position_artifact_cap_middleware import PositionArtifactCapMiddleware
|
||||
|
||||
middlewares.append(PositionArtifactCapMiddleware())
|
||||
|
||||
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||||
middlewares.append(LoopDetectionMiddleware())
|
||||
|
||||
|
||||
+84
-76
@@ -11,7 +11,6 @@ from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware, hook_config
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langgraph.graph import END
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
@@ -155,47 +154,30 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
"allow_multiple": resolve_allow_multiple(args),
|
||||
}
|
||||
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||
"""Handle clarification request and return command to interrupt execution.
|
||||
def _parse_tool_args(self, tool_call: Any) -> dict[str, Any]:
|
||||
args = self._tool_field(tool_call, "args") or {}
|
||||
if isinstance(args, dict):
|
||||
return args
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
parsed = json.loads(args)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> ToolMessage:
|
||||
"""Return the clarification ToolMessage without Command(goto=END).
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
Stopping the turn is ``after_model`` + ``jump_to=end``. Returning a
|
||||
Command here used to mix with parallel tool results and loop the graph.
|
||||
If the tools node still runs (jump_to not honored), a ToolMessage with
|
||||
the same stable id replaces rather than appends.
|
||||
"""
|
||||
# Extract clarification arguments
|
||||
args = request.tool_call.get("args", {})
|
||||
question = args.get("question", "")
|
||||
|
||||
logger.info("Intercepted clarification request")
|
||||
logger.debug("Clarification question: %s", question)
|
||||
|
||||
# Format the clarification message
|
||||
formatted_message = self._format_clarification_message(args)
|
||||
|
||||
# Get the tool call ID
|
||||
tool_call_id = request.tool_call.get("id", "")
|
||||
|
||||
# Create a ToolMessage with the formatted question
|
||||
# This will be added to the message history
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
|
||||
# Return a Command that:
|
||||
# 1. Adds the formatted tool message
|
||||
# 2. Interrupts execution by going to __end__
|
||||
# Note: We don't add an extra AIMessage here - the frontend will detect
|
||||
# and display ask_clarification tool messages directly
|
||||
return Command(
|
||||
update={"messages": [tool_message]},
|
||||
goto=END,
|
||||
)
|
||||
args = self._parse_tool_args(request.tool_call)
|
||||
tool_call_id = str(self._tool_field(request.tool_call, "id") or "")
|
||||
logger.info("Intercepted clarification request in wrap_tool_call")
|
||||
return self._clarification_tool_message(tool_call_id, args)
|
||||
|
||||
def _tool_field(self, tool_call: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(tool_call, dict):
|
||||
@@ -205,6 +187,21 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
return getter(key, default)
|
||||
return getattr(tool_call, key, default)
|
||||
|
||||
def _tool_name(self, tool_call: Any) -> str:
|
||||
"""Normalize tool names from dict / object / OpenAI-style nested function."""
|
||||
raw = self._tool_field(tool_call, "name")
|
||||
if raw is None and isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function")
|
||||
if isinstance(fn, dict):
|
||||
raw = fn.get("name")
|
||||
name = str(raw or "").strip()
|
||||
if "." in name:
|
||||
name = name.rsplit(".", 1)[-1]
|
||||
return name
|
||||
|
||||
def _is_clarification_call(self, tool_call: Any) -> bool:
|
||||
return self._tool_name(tool_call).casefold() == "ask_clarification"
|
||||
|
||||
def _clarification_tool_message(self, tool_call_id: str, args: dict[str, Any]) -> ToolMessage:
|
||||
formatted_message = self._format_clarification_message(args)
|
||||
return ToolMessage(
|
||||
@@ -218,6 +215,8 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
def _after_model_intercept(self, last: AIMessage) -> dict[str, Any] | None:
|
||||
"""Stop the run as soon as the model emits ask_clarification.
|
||||
|
||||
Applies to **all** lead-agent chats (通用问答 / 智能体 / 写作配置 / 报告结构…)。
|
||||
|
||||
``wrap_tool_call`` + ``Command(goto=END)`` is unreliable with parallel
|
||||
tool calls (weak models often emit two clarifications, or clarification
|
||||
plus another tool): the Command mixes with other tool results and the
|
||||
@@ -225,20 +224,16 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
as "the user still hasn't answered" and keeps generating.
|
||||
"""
|
||||
tool_calls = list(last.tool_calls or [])
|
||||
clarification_calls = [
|
||||
tc for tc in tool_calls if self._tool_field(tc, "name") == "ask_clarification"
|
||||
]
|
||||
clarification_calls = [tc for tc in tool_calls if self._is_clarification_call(tc)]
|
||||
if not clarification_calls:
|
||||
return None
|
||||
|
||||
messages: list[ToolMessage] = []
|
||||
for tc in tool_calls:
|
||||
name = str(self._tool_field(tc, "name") or "")
|
||||
name = self._tool_name(tc) or "unknown"
|
||||
tool_call_id = str(self._tool_field(tc, "id") or "")
|
||||
args = self._tool_field(tc, "args") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
if name == "ask_clarification":
|
||||
args = self._parse_tool_args(tc)
|
||||
if self._is_clarification_call(tc):
|
||||
messages.append(self._clarification_tool_message(tool_call_id, args))
|
||||
else:
|
||||
messages.append(
|
||||
@@ -246,7 +241,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
id=f"clarification-skip:{tool_call_id or name}",
|
||||
content="已暂停:需要先等待用户回答澄清问题,本工具未执行。",
|
||||
tool_call_id=tool_call_id,
|
||||
name=name or "unknown",
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -280,27 +275,50 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
) -> dict[str, Any] | None:
|
||||
return self.after_model(state, runtime)
|
||||
|
||||
@override
|
||||
def _stub_sibling_tool(self, request: ToolCallRequest) -> ToolMessage:
|
||||
name = self._tool_name(request.tool_call) or "unknown"
|
||||
tool_call_id = str(self._tool_field(request.tool_call, "id") or "")
|
||||
return ToolMessage(
|
||||
id=f"clarification-skip:{tool_call_id or name}",
|
||||
content="已暂停:需要先等待用户回答澄清问题,本工具未执行。",
|
||||
tool_call_id=tool_call_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
def _request_has_sibling_clarification(self, request: ToolCallRequest) -> bool:
|
||||
state = getattr(request, "state", None)
|
||||
messages: Any = None
|
||||
if isinstance(state, dict):
|
||||
messages = state.get("messages")
|
||||
elif state is not None:
|
||||
getter = getattr(state, "get", None)
|
||||
if callable(getter):
|
||||
messages = getter("messages")
|
||||
else:
|
||||
messages = getattr(state, "messages", None)
|
||||
if not messages:
|
||||
return False
|
||||
last = messages[-1]
|
||||
tool_calls = getattr(last, "tool_calls", None) or []
|
||||
return any(self._is_clarification_call(tc) for tc in tool_calls)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
"""Intercept ask_clarification tool calls and interrupt execution (sync version).
|
||||
"""Complete clarification tool calls; do not Command(goto=END).
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
handler: Original tool execution handler
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
Turn-stop is ``after_model`` + ``jump_to=end``. If the tools node still
|
||||
runs, return a ToolMessage so parallel tools do not mix Commands.
|
||||
"""
|
||||
# Check if this is an ask_clarification tool call
|
||||
if request.tool_call.get("name") != "ask_clarification":
|
||||
# Not a clarification call, execute normally
|
||||
return handler(request)
|
||||
|
||||
return self._handle_clarification(request)
|
||||
if self._is_clarification_call(request.tool_call):
|
||||
return self._handle_clarification(request)
|
||||
if self._request_has_sibling_clarification(request):
|
||||
return self._stub_sibling_tool(request)
|
||||
return handler(request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
@@ -308,18 +326,8 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
"""Intercept ask_clarification tool calls and interrupt execution (async version).
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
handler: Original tool execution handler (async)
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
"""
|
||||
# Check if this is an ask_clarification tool call
|
||||
if request.tool_call.get("name") != "ask_clarification":
|
||||
# Not a clarification call, execute normally
|
||||
return await handler(request)
|
||||
|
||||
return self._handle_clarification(request)
|
||||
if self._is_clarification_call(request.tool_call):
|
||||
return self._handle_clarification(request)
|
||||
if self._request_has_sibling_clarification(request):
|
||||
return self._stub_sibling_tool(request)
|
||||
return await handler(request)
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
"""Cap ``write_file`` deliveries for position-roundtable seats.
|
||||
|
||||
Weak models (especially after a first successful Markdown write) often call
|
||||
``write_file`` again with another copy of the same report and loop until the
|
||||
recursion limit kills the run.
|
||||
|
||||
When the run context carries ``position_artifact_cap`` (set by the position-
|
||||
roundtable UI for ordinary seats and the summary agent — **not** for
|
||||
action-plan, which must emit report + JSON), this middleware:
|
||||
|
||||
1. Truncates excess outputs ``write_file`` tool calls in a single model
|
||||
response (keeps only the first *N*), including raw provider metadata.
|
||||
2. Rejects further outputs ``write_file`` calls once *N* successful *or*
|
||||
earlier in-batch writes already exist in the current user turn (so
|
||||
parallel tool calls cannot race past the cap).
|
||||
3. Ends the turn when the only remaining calls were excess writes.
|
||||
4. Injects a brief hidden reminder before the next model call so the agent
|
||||
wraps up with ``present_files`` / a short reply instead of rewriting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware, hook_config
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.runtime import Runtime
|
||||
from langgraph.types import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONTEXT_KEY = "position_artifact_cap"
|
||||
_OUTPUTS_PREFIX = "/mnt/user-data/outputs"
|
||||
_WRITE_TOOL = "write_file"
|
||||
|
||||
_REJECT_MSG = (
|
||||
"本席位本轮产物上限已达:outputs 下只允许写入一份交付文件。"
|
||||
"请勿再次调用 write_file;若尚未呈现文件请调用 present_files,"
|
||||
"然后用简短文字说明交付已完成。"
|
||||
)
|
||||
|
||||
_REMINDER = (
|
||||
"【系统提示】本席位本轮产物文件已成功写入 outputs。"
|
||||
"禁止再次调用 write_file 生成第二份(或相同)产物。"
|
||||
"请立即 present_files(如尚未呈现),并给出简短收尾回复后结束本轮。"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_args(raw_args: object) -> dict[str, Any]:
|
||||
if isinstance(raw_args, dict):
|
||||
return raw_args
|
||||
if isinstance(raw_args, str):
|
||||
try:
|
||||
parsed = json.loads(raw_args)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _tool_field(tool_call: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(tool_call, dict):
|
||||
return tool_call.get(key, default)
|
||||
getter = getattr(tool_call, "get", None)
|
||||
if callable(getter):
|
||||
return getter(key, default)
|
||||
return getattr(tool_call, key, default)
|
||||
|
||||
|
||||
def _is_outputs_path(path: object) -> bool:
|
||||
text = str(path or "").strip().replace("\\", "/")
|
||||
if not text:
|
||||
return False
|
||||
if text == _OUTPUTS_PREFIX or text.startswith(f"{_OUTPUTS_PREFIX}/"):
|
||||
return True
|
||||
lowered = text.lstrip("./")
|
||||
return lowered == "outputs" or lowered.startswith("outputs/")
|
||||
|
||||
|
||||
def _tool_call_writes_outputs(tool_call: Any) -> bool:
|
||||
if _tool_field(tool_call, "name") != _WRITE_TOOL:
|
||||
return False
|
||||
args = _normalize_args(_tool_field(tool_call, "args"))
|
||||
return _is_outputs_path(args.get("path"))
|
||||
|
||||
|
||||
def _cap_from_mapping(mapping: object) -> int | None:
|
||||
if not isinstance(mapping, dict):
|
||||
return None
|
||||
raw = mapping.get(_CONTEXT_KEY)
|
||||
if raw is None or raw is False:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def _cap_from_runtime(runtime: object | None) -> int | None:
|
||||
if runtime is None:
|
||||
return None
|
||||
context = getattr(runtime, "context", None)
|
||||
cap = _cap_from_mapping(context)
|
||||
if cap is not None:
|
||||
return cap
|
||||
config = getattr(runtime, "config", None)
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
for bucket_key in ("configurable", "metadata", "context"):
|
||||
cap = _cap_from_mapping(config.get(bucket_key))
|
||||
if cap is not None:
|
||||
return cap
|
||||
return None
|
||||
|
||||
|
||||
def _is_visible_human(message: Any) -> bool:
|
||||
if getattr(message, "type", None) != "human":
|
||||
return False
|
||||
kwargs = getattr(message, "additional_kwargs", None) or {}
|
||||
if kwargs.get("hide_from_ui"):
|
||||
return False
|
||||
name = getattr(message, "name", None)
|
||||
if name in {"loop_warning", "position_artifact_cap"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _current_turn_start(messages: list[Any]) -> int:
|
||||
for index in range(len(messages) - 1, -1, -1):
|
||||
if _is_visible_human(messages[index]):
|
||||
return index
|
||||
return 0
|
||||
|
||||
|
||||
def _successful_outputs_writes(messages: list[Any], *, turn_start: int) -> int:
|
||||
"""Count successful outputs ``write_file`` calls in the current user turn."""
|
||||
outputs_ids: set[str] = set()
|
||||
success = 0
|
||||
for message in messages[turn_start:]:
|
||||
msg_type = getattr(message, "type", None)
|
||||
if msg_type == "ai":
|
||||
for tool_call in getattr(message, "tool_calls", None) or []:
|
||||
if not _tool_call_writes_outputs(tool_call):
|
||||
continue
|
||||
call_id = _tool_field(tool_call, "id")
|
||||
if isinstance(call_id, str) and call_id:
|
||||
outputs_ids.add(call_id)
|
||||
continue
|
||||
if msg_type != "tool":
|
||||
continue
|
||||
call_id = getattr(message, "tool_call_id", None)
|
||||
if not isinstance(call_id, str) or call_id not in outputs_ids:
|
||||
continue
|
||||
content = str(getattr(message, "content", "") or "").strip()
|
||||
if content == "OK" or (content and not content.startswith("Error")):
|
||||
success += 1
|
||||
return success
|
||||
|
||||
|
||||
def _kept_call_ids(tool_calls: list[Any]) -> set[str]:
|
||||
ids: set[str] = set()
|
||||
for tool_call in tool_calls:
|
||||
call_id = _tool_field(tool_call, "id")
|
||||
if isinstance(call_id, str) and call_id:
|
||||
ids.add(call_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _build_truncated_update(last_msg: Any, kept: list[Any]) -> dict[str, Any]:
|
||||
"""Replace structured tool_calls and keep raw provider metadata in sync."""
|
||||
update: dict[str, Any] = {"tool_calls": kept}
|
||||
additional_kwargs = dict(getattr(last_msg, "additional_kwargs", {}) or {})
|
||||
raw_calls = additional_kwargs.get("tool_calls")
|
||||
kept_ids = _kept_call_ids(kept)
|
||||
if isinstance(raw_calls, list) and raw_calls:
|
||||
if not kept:
|
||||
additional_kwargs.pop("tool_calls", None)
|
||||
additional_kwargs.pop("function_call", None)
|
||||
else:
|
||||
filtered = [item for item in raw_calls if isinstance(item, dict) and item.get("id") in kept_ids]
|
||||
if filtered:
|
||||
additional_kwargs["tool_calls"] = filtered
|
||||
else:
|
||||
additional_kwargs.pop("tool_calls", None)
|
||||
additional_kwargs.pop("function_call", None)
|
||||
elif not kept:
|
||||
additional_kwargs.pop("tool_calls", None)
|
||||
additional_kwargs.pop("function_call", None)
|
||||
update["additional_kwargs"] = additional_kwargs
|
||||
if not kept:
|
||||
response_metadata = deepcopy(getattr(last_msg, "response_metadata", {}) or {})
|
||||
if response_metadata.get("finish_reason") == "tool_calls":
|
||||
response_metadata["finish_reason"] = "stop"
|
||||
update["response_metadata"] = response_metadata
|
||||
return update
|
||||
|
||||
|
||||
def _outputs_budget_used(messages: list[Any], *, turn_start: int, current_id: str | None) -> int:
|
||||
"""Successful writes this turn, plus earlier sibling writes in the same AI batch."""
|
||||
used = _successful_outputs_writes(messages, turn_start=turn_start)
|
||||
if not current_id:
|
||||
return used
|
||||
last_ai = None
|
||||
for message in reversed(messages):
|
||||
if getattr(message, "type", None) == "ai":
|
||||
last_ai = message
|
||||
break
|
||||
if last_ai is None:
|
||||
return used
|
||||
batch = list(getattr(last_ai, "tool_calls", None) or [])
|
||||
if current_id not in {str(_tool_field(tool_call, "id") or "") for tool_call in batch}:
|
||||
return used
|
||||
for tool_call in batch:
|
||||
call_id = str(_tool_field(tool_call, "id") or "")
|
||||
if call_id == current_id:
|
||||
break
|
||||
if _tool_call_writes_outputs(tool_call):
|
||||
used += 1
|
||||
return used
|
||||
|
||||
|
||||
class PositionArtifactCapMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Enforce a per-turn outputs ``write_file`` budget for position roundtable."""
|
||||
|
||||
state_schema = AgentState
|
||||
|
||||
def _truncate_excess_writes(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
cap = _cap_from_runtime(runtime)
|
||||
if cap is None:
|
||||
return None
|
||||
|
||||
messages = list(state.get("messages") or [])
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
last_msg = messages[-1]
|
||||
if getattr(last_msg, "type", None) != "ai":
|
||||
return None
|
||||
|
||||
tool_calls = list(getattr(last_msg, "tool_calls", None) or [])
|
||||
if not tool_calls:
|
||||
return None
|
||||
|
||||
already = _successful_outputs_writes(messages[:-1], turn_start=_current_turn_start(messages))
|
||||
remaining = max(0, cap - already)
|
||||
|
||||
kept: list[Any] = []
|
||||
dropped = 0
|
||||
kept_outputs = 0
|
||||
for tool_call in tool_calls:
|
||||
if _tool_call_writes_outputs(tool_call):
|
||||
if kept_outputs >= remaining:
|
||||
dropped += 1
|
||||
continue
|
||||
kept_outputs += 1
|
||||
kept.append(tool_call)
|
||||
|
||||
if dropped == 0:
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Position artifact cap truncated %s excess write_file call(s) (cap=%s, already=%s)",
|
||||
dropped,
|
||||
cap,
|
||||
already,
|
||||
)
|
||||
updated = last_msg.model_copy(update=_build_truncated_update(last_msg, kept))
|
||||
result: dict[str, Any] = {"messages": [updated]}
|
||||
if not kept:
|
||||
# No remaining tools — stop instead of looping back to the model
|
||||
# which would just emit another copy of write_file.
|
||||
result["jump_to"] = "end"
|
||||
return result
|
||||
|
||||
def _reject_over_cap(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
) -> ToolMessage | None:
|
||||
tool_call = getattr(request, "tool_call", None) or {}
|
||||
if not _tool_call_writes_outputs(tool_call):
|
||||
return None
|
||||
|
||||
runtime = getattr(request, "runtime", None)
|
||||
cap = _cap_from_runtime(runtime)
|
||||
if cap is None:
|
||||
return None
|
||||
|
||||
state = getattr(request, "state", None) or {}
|
||||
messages = list(state.get("messages") or []) if isinstance(state, dict) else []
|
||||
current_id = str(_tool_field(tool_call, "id") or "")
|
||||
used = _outputs_budget_used(messages, turn_start=_current_turn_start(messages), current_id=current_id or None)
|
||||
if used < cap:
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Position artifact cap rejected write_file (cap=%s, used=%s, id=%s)",
|
||||
cap,
|
||||
used,
|
||||
current_id,
|
||||
)
|
||||
return ToolMessage(
|
||||
content=f"Error: {_REJECT_MSG}",
|
||||
tool_call_id=current_id,
|
||||
name=_WRITE_TOOL,
|
||||
)
|
||||
|
||||
def _maybe_remind(self, request: ModelRequest) -> ModelRequest:
|
||||
cap = _cap_from_runtime(getattr(request, "runtime", None))
|
||||
if cap is None:
|
||||
return request
|
||||
messages = list(getattr(request, "messages", None) or [])
|
||||
already = _successful_outputs_writes(messages, turn_start=_current_turn_start(messages))
|
||||
if already < cap:
|
||||
return request
|
||||
reminder = HumanMessage(
|
||||
content=_REMINDER,
|
||||
name="position_artifact_cap",
|
||||
additional_kwargs={"hide_from_ui": True, "lc_source": "position_artifact_cap"},
|
||||
)
|
||||
return request.override(messages=[*messages, reminder])
|
||||
|
||||
@hook_config(can_jump_to=["end"])
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._truncate_excess_writes(state, runtime)
|
||||
|
||||
@hook_config(can_jump_to=["end"])
|
||||
@override
|
||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._truncate_excess_writes(state, runtime)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
rejected = self._reject_over_cap(request)
|
||||
if rejected is not None:
|
||||
return rejected
|
||||
return handler(request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
rejected = self._reject_over_cap(request)
|
||||
if rejected is not None:
|
||||
return rejected
|
||||
return await handler(request)
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
return handler(self._maybe_remind(request))
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelCallResult:
|
||||
return await handler(self._maybe_remind(request))
|
||||
+27
-3
@@ -15,9 +15,33 @@ three-state semantics (see :mod:`deerflow.runtime.user_context`):
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import Any
|
||||
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
||||
|
||||
# Configuration wizards / machine workers that must not appear in the default
|
||||
# conversation list. Prefer ``metadata.system=true`` on create; this set also
|
||||
# covers older rows that only set ``thread_type``.
|
||||
EXCLUDED_SYSTEM_THREAD_TYPES = frozenset(
|
||||
{
|
||||
"scheduler",
|
||||
"roundtable",
|
||||
"notebook",
|
||||
"deep_research",
|
||||
"writing_setup",
|
||||
"report_structure_setup",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_excluded_system_thread(metadata: dict[str, Any] | None) -> bool:
|
||||
"""Return True when a thread should be hidden from user conversation lists."""
|
||||
md = metadata or {}
|
||||
if md.get("system"):
|
||||
return True
|
||||
thread_type = md.get("thread_type")
|
||||
return isinstance(thread_type, str) and thread_type in EXCLUDED_SYSTEM_THREAD_TYPES
|
||||
|
||||
|
||||
class ThreadMetaStore(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
@@ -56,9 +80,9 @@ class ThreadMetaStore(abc.ABC):
|
||||
) -> list[dict]:
|
||||
"""Search threads ordered by ``updated_at`` desc.
|
||||
|
||||
``exclude_system=True`` drops threads whose ``metadata.system`` is
|
||||
truthy (scheduler runs, roundtable workers, …) so user-facing
|
||||
conversation lists are not crowded out by machine-created threads.
|
||||
``exclude_system=True`` drops machine / wizard threads (``metadata.system``
|
||||
or known ``thread_type`` values such as scheduler / report_structure_setup)
|
||||
so user-facing conversation lists are not crowded out.
|
||||
``query`` is a case-insensitive substring match on the thread title
|
||||
(``display_name``). ``limit``/``offset`` apply to the *filtered*
|
||||
result set.
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ from typing import Any
|
||||
|
||||
from langgraph.store.base import BaseStore
|
||||
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore, is_excluded_system_thread
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
from deerflow.utils.time import coerce_iso, now_iso
|
||||
|
||||
@@ -107,7 +107,7 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
# until the requested window is full so ``limit``/``offset`` refer to
|
||||
# filtered rows.
|
||||
def _matches(record: dict) -> bool:
|
||||
if exclude_system and (record.get("metadata") or {}).get("system"):
|
||||
if exclude_system and is_excluded_system_thread(record.get("metadata")):
|
||||
return False
|
||||
if normalized_query and normalized_query not in (record.get("display_name") or "").lower():
|
||||
return False
|
||||
@@ -169,7 +169,7 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
break
|
||||
for item in items:
|
||||
record = self._item_to_dict(item)
|
||||
if exclude_system and (record.get("metadata") or {}).get("system"):
|
||||
if exclude_system and is_excluded_system_thread(record.get("metadata")):
|
||||
continue
|
||||
if normalized_query and normalized_query not in (record.get("display_name") or "").lower():
|
||||
continue
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ from typing import Any
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore, is_excluded_system_thread
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
|
||||
@@ -166,7 +166,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
md = row.get("metadata") or {}
|
||||
if metadata and not all(md.get(k) == v for k, v in metadata.items()):
|
||||
return False
|
||||
if exclude_system and md.get("system"):
|
||||
if exclude_system and is_excluded_system_thread(md):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -233,7 +233,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
md = metadata_json or {}
|
||||
if metadata and not all(md.get(k) == v for k, v in metadata.items()):
|
||||
continue
|
||||
if exclude_system and md.get("system"):
|
||||
if exclude_system and is_excluded_system_thread(md):
|
||||
continue
|
||||
total += 1
|
||||
if len(rows) < batch_size:
|
||||
|
||||
Reference in new issue
Block a user