fix: 协助卡停轮与提交防 409,隐藏配置向导会话,并限制岗位会商产物重复写入

澄清中间件改为 ToolMessage + after_model 停轮,前端提交前取消残留 run;系统/向导 thread_type 从对话列表排除;岗位总结席位加 write_file 上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-23 20:29:59 +08:00
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) {
+8 -2
View File
@@ -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
+44 -13
View File
@@ -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,
};
},
+38
View File
@@ -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(
+5 -4
View File
@@ -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 () => {
+5 -4
View File
@@ -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(() => {
+5 -4
View File
@@ -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(() => {
+5 -4
View File
@@ -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) => {
+15 -3
View File
@@ -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}
+20
View File
@@ -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())
@@ -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)
@@ -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))
@@ -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.
@@ -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
@@ -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: