Compare commits

..
1 Commits
Author SHA1 Message Date
gozeros 1d19260673 sync snapshot from LAN repo (source dhk-v5 @ d1e57306) 2026-11-05 11:03:03 +08:00
29 changed files with 106 additions and 652 deletions

No files matched your search

-3
View File
@@ -112,9 +112,6 @@ window.__CM_RUNTIME_CONFIG__ = {
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
// B 系统(XWT检索)头像代理;按部署环境设置为浏览器可访问的地址。
VITE_AIPI_MINIO_PROXY_URL: "http://minio-proxy.b.sit.4.cn/facebook-proxy",
// B 系统 knowes 根地址。
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
@@ -599,8 +599,8 @@ const DocumentUploadPopupContent: FC<{
};
const yUserId = getYUserId();
const notebookToken = getNotebookToken();
const iframeTokenParam = notebookToken ? `&token=${encodeURIComponent(notebookToken)}` : "";
const userToken = getStoredAuth()?.access_token;
const iframeTokenParam = userToken ? `&token=${encodeURIComponent(userToken)}` : "";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
@@ -188,12 +188,9 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
const [directionType, setDirectionType] = useState<string>('TW');
const directionValue = useRef<string>('TW');
const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || 'https://ch1.b.uat.4.cn/consumer';
const minioProxyUrl = readRuntimeEnv("VITE_AIPI_MINIO_PROXY_URL") || 'http://minio-proxy.b.sit.4.cn/facebook-proxy';
const actorBaseUrl = readRuntimeEnv("VITE_AIPI_B2ACTOR_URL") || 'http://ty.b.uat.4.cn';
const avatarUrl = (profileImage: string | null) => profileImage
? `${minioProxyUrl}?${new URLSearchParams({ url: profileImage, is_avatar: 'true' })}`
: `${actorBaseUrl}/manage/img/default.png`;
const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || 'http://localhost:8001';
const backendUrl = typeof window !== 'undefined'
? (readRuntimeEnv("VITE_BACKEND_BASE_URL") || 'http://localhost:8001')
: 'http://localhost:8001';
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
const [isImporting, setIsImporting] = useState(false);
@@ -291,7 +288,9 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
setIsSearching(false);
const result: UserDataItem[] = data.data.list;
result.forEach((item) => {
item.profile_image = avatarUrl(item.profile_image);
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo['账号描述'];
@@ -331,7 +330,9 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
const result: UserDataItem[] = data.data.data;
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = avatarUrl(item.profile_image);
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}`;
item.title = item.group_name || item.groupName;
if (item.hx_info?.['用户画像']) {
@@ -16,7 +16,6 @@ export function ContextWindowSettingsPage() {
const update = useUpdateConversationContextSettings();
const enabled = data?.recent_turn_window_enabled ?? false;
const recentTurns = data?.recent_turns ?? 3;
const useQuestionAsTitle = data?.use_question_as_title ?? false;
const handleChange = async (checked: boolean) => {
try {
@@ -31,19 +30,6 @@ export function ContextWindowSettingsPage() {
}
};
const handleQuestionTitleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ use_question_as_title: checked });
toast.success(
checked
? "已开启:对话标题直接使用首个问题"
: "已关闭:恢复回答后的标题生成",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
@@ -76,25 +62,6 @@ export function ContextWindowSettingsPage() {
)}
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">标题直接使用首个问题</div>
<p className="text-muted-foreground text-sm">
开启后,首个问题会立即成为最终标题;回答结束后不再额外调用模型生成标题,也不轮询标题状态,避免影响下一轮提问。关闭后恢复原来的回答后标题生成方式。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={useQuestionAsTitle}
disabled={isError || update.isPending}
onCheckedChange={handleQuestionTitleChange}
aria-label="标题直接使用首个问题"
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
@@ -1,6 +1,6 @@
"use client";
import { Clock3Icon, FileOutputIcon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
import { Clock3Icon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -9,7 +9,6 @@ import { Switch } from "@/components/ui/switch";
import {
useRoundtableRoomLimitSettings,
useUpdateRoundtableRoomLimitSettings,
useWriteFileCompatibilitySettings,
} from "@/core/system-settings/hooks";
import { loadRoundtableRoomSummary } from "@/roundtable-planning/api/rooms";
@@ -17,10 +16,7 @@ export function RoundtableRoomSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useRoundtableRoomLimitSettings();
const update = useUpdateRoundtableRoomLimitSettings();
const globalFileStream = useWriteFileCompatibilitySettings();
const enabled = data?.enabled ?? true;
const fileStreamEnabled = data?.write_file_compatibility_enabled ?? false;
const globalFileStreamEnabled = globalFileStream.data?.enabled ?? false;
const [maxRooms, setMaxRooms] = useState(String(data?.max_rooms ?? 3));
const [maxHoldMinutes, setMaxHoldMinutes] = useState(
String(data?.max_hold_minutes ?? 30),
@@ -66,7 +62,6 @@ export function RoundtableRoomSettingsPage() {
max_rooms?: number;
max_hold_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}) => {
try {
await update.mutateAsync(next);
@@ -220,37 +215,6 @@ export function RoundtableRoomSettingsPage() {
</Button>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="flex items-center gap-2 font-medium">
<FileOutputIcon className="size-4" />
会商文件流式兼容
</div>
<p className="text-muted-foreground text-sm">
会商生成文件并打开沙箱时使用兼容写入。全局「文件流式兼容」开着时,会商自动使用;全局关着时,打开本开关后只有会商使用,普通对话保持原来的写入方式。
</p>
<p className="text-muted-foreground text-xs">
{globalFileStreamEnabled
? "当前会商会使用兼容写入,因为全局开关已开启。"
: fileStreamEnabled
? "当前只有会商使用兼容写入。"
: "当前会商使用各模型原来的写入方式。"}
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={fileStreamEnabled}
disabled={isError || update.isPending}
onCheckedChange={(checked) =>
void save({ write_file_compatibility_enabled: checked })
}
aria-label="会商启用文件流式兼容"
/>
)}
</div>
<p className="text-muted-foreground text-sm">
当前全站占用:{activeCount == null ? "暂时无法读取" : `${activeCount} / ${data?.max_rooms ?? maxRooms}`}
</p>
@@ -1,14 +1,6 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type ModelWriteFileTransport =
| "native"
| "buffered_gateway"
| "artifact_renderer"
| "text_bridge";
export type ModelWriteFileTransportToggle = "native" | "buffered_gateway";
export interface ManagedModel {
name: string;
display_name: string | null;
@@ -31,7 +23,6 @@ export interface ManagedModel {
thinking: Record<string, unknown> | null;
use_responses_api: boolean | null;
output_version: string | null;
write_file_transport: ModelWriteFileTransport;
extra_settings: Record<string, unknown>;
api_key_configured: boolean;
api_key_source: string | null;
@@ -129,22 +120,6 @@ export async function updateManagedModelReasoningEffortCapability(
return response.json() as Promise<ManagedModel>;
}
export async function updateManagedModelWriteFileTransport(
name: string,
writeFileTransport: ModelWriteFileTransportToggle,
): Promise<ManagedModel> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/models/write-file-transport/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ write_file_transport: writeFileTransport }),
},
);
if (!response.ok) return readError(response, "保存文件流式兼容设置失败");
return response.json() as Promise<ManagedModel>;
}
export async function deleteManagedModel(name: string): Promise<void> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
method: "DELETE",
@@ -8,9 +8,7 @@ import {
testManagedModel,
updateManagedModel,
updateManagedModelReasoningEffortCapability,
updateManagedModelWriteFileTransport,
type ModelSavePayload,
type ModelWriteFileTransportToggle,
} from "./api";
export const MANAGED_MODELS_QUERY_KEY = ["admin", "model-config"] as const;
@@ -68,25 +66,6 @@ export function useUpdateManagedModelReasoningEffortCapability() {
});
}
export function useUpdateManagedModelWriteFileTransport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
name,
writeFileTransport,
}: {
name: string;
writeFileTransport: ModelWriteFileTransportToggle;
}) => updateManagedModelWriteFileTransport(name, writeFileTransport),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
export function useDeleteManagedModel() {
const queryClient = useQueryClient();
return useMutation({
+1 -5
View File
@@ -728,12 +728,10 @@ export interface MemoryRuntimeSettings {
export interface ConversationContextSettings {
recent_turn_window_enabled: boolean;
recent_turns: number;
use_question_as_title: boolean;
}
export interface ConversationContextUpdate {
recent_turn_window_enabled?: boolean;
use_question_as_title?: boolean;
recent_turn_window_enabled: boolean;
}
export interface RoundtableRoomLimitSettings {
@@ -741,7 +739,6 @@ export interface RoundtableRoomLimitSettings {
max_rooms: number;
max_hold_minutes: number;
max_cycles: number;
write_file_compatibility_enabled: boolean;
}
export interface RoundtableRoomLimitUpdate {
@@ -749,7 +746,6 @@ export interface RoundtableRoomLimitUpdate {
max_rooms?: number;
max_hold_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}
export interface OrdinaryKnowledgeRetrievalSettings {
+43 -43
View File
@@ -1340,11 +1340,11 @@ export function useThreadStream({
runId = inflight?.run_id ?? null;
if (runId) {
activeRunIdRef.current = runId;
} else {
const lastTerminal = runs.find((item) =>
TERMINAL_RUN_STATUSES.has(item.status),
);
if (lastTerminal) terminalStatus = lastTerminal.status;
} else {
const lastTerminal = runs.find((item) =>
TERMINAL_RUN_STATUSES.has(item.status),
);
if (lastTerminal) terminalStatus = lastTerminal.status;
}
} catch {
if (!isCurrentRecovery()) return "unknown";
@@ -1357,45 +1357,45 @@ export function useThreadStream({
}
}
if (terminalStatus) {
try {
return (await applyTerminalState(terminalStatus))
? "terminal"
: "unknown";
} catch {
if (isCurrentRecovery()) {
lastStreamActivityAtRef.current = Date.now();
setStreamHoldOpen(true);
}
return "unknown";
}
}
if (terminalStatus) {
try {
return (await applyTerminalState(terminalStatus))
? "terminal"
: "unknown";
} catch {
if (isCurrentRecovery()) {
lastStreamActivityAtRef.current = Date.now();
setStreamHoldOpen(true);
}
return "unknown";
}
}
if (!runId) {
// The backend confirms there is no in-flight run, but an SDK reader
// can remain locally open after a proxy/SSE disconnect. Detach that
// reader only after removing its reconnect marker so stop() cannot
// accidentally cancel a server run.
try {
const readerStopped = await stopRecoveredRunReader({
isCurrent: isCurrentRecovery,
clearReconnect: () => clearActiveRunId(tid),
stop: () => threadStreamRef.current?.stop(),
});
if (!readerStopped) return "unknown";
activeRunIdRef.current = null;
setStreamHoldOpen(false);
streamReconnectAttemptsRef.current = 0;
backgroundHistory.confirmTerminal();
void backgroundHistory.refresh(false);
return "terminal";
} catch {
if (isCurrentRecovery()) {
lastStreamActivityAtRef.current = Date.now();
setStreamHoldOpen(true);
}
return "unknown";
}
// The backend confirms there is no in-flight run, but an SDK reader
// can remain locally open after a proxy/SSE disconnect. Detach that
// reader only after removing its reconnect marker so stop() cannot
// accidentally cancel a server run.
try {
const readerStopped = await stopRecoveredRunReader({
isCurrent: isCurrentRecovery,
clearReconnect: () => clearActiveRunId(tid),
stop: () => threadStreamRef.current?.stop(),
});
if (!readerStopped) return "unknown";
activeRunIdRef.current = null;
setStreamHoldOpen(false);
streamReconnectAttemptsRef.current = 0;
backgroundHistory.confirmTerminal();
void backgroundHistory.refresh(false);
return "terminal";
} catch {
if (isCurrentRecovery()) {
lastStreamActivityAtRef.current = Date.now();
setStreamHoldOpen(true);
}
return "unknown";
}
}
// A second UI failure can pause recovery while /runs is in flight.
@@ -22,7 +22,6 @@ import {
CircleAlertIcon,
CpuIcon,
EyeIcon,
FileOutputIcon,
GripVerticalIcon,
KeyRoundIcon,
LoaderCircleIcon,
@@ -55,9 +54,7 @@ import {
useSaveManagedModel,
useTestManagedModel,
useUpdateManagedModelReasoningEffortCapability,
useUpdateManagedModelWriteFileTransport,
} from "@/core/model-management";
import { useWriteFileCompatibilitySettings } from "@/core/system-settings/hooks";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -200,44 +197,25 @@ function SortableModelCard({
dragEnabled,
reasoningCapabilityDisabled,
reasoningCapabilityPending,
globalWriteFileCompatEnabled,
writeFileCompatPending,
onConfigure,
onDelete,
onReasoningCapabilityChange,
onReasoningEffortChange,
onWriteFileCompatChange,
}: {
model: ManagedModel;
isDefault: boolean;
dragEnabled: boolean;
reasoningCapabilityDisabled: boolean;
reasoningCapabilityPending: boolean;
globalWriteFileCompatEnabled: boolean;
writeFileCompatPending: boolean;
onConfigure: () => void;
onDelete: () => void;
onReasoningCapabilityChange: (enabled: boolean) => void;
onReasoningEffortChange: (effort: "low" | "medium" | "xhigh") => void;
onWriteFileCompatChange: (enabled: boolean) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: model.name,
disabled: !dragEnabled,
});
const writeFileCompatEligible =
(model.use === "langchain_openai:ChatOpenAI" ||
model.use === "deerflow.models.patched_openai:PatchedChatOpenAI") &&
!model.use_responses_api;
const perModelWriteFileCompat = model.write_file_transport === "buffered_gateway";
const writeFileCompatActive = perModelWriteFileCompat || globalWriteFileCompatEnabled;
const writeFileCompatHint = globalWriteFileCompatEnabled
? "全局文件流式兼容已开启,所有兼容模型均已强制启用;关闭全局开关后恢复此处的单独设置。"
: !writeFileCompatEligible
? "仅支持 OpenAI 兼容接口(Chat Completions)模型。"
: perModelWriteFileCompat
? "该模型已启用:文件正文改由普通文本流生成,适配缓存长工具参数的网关。"
: "针对会缓存长工具参数的内网模型网关;开启后文件正文改由普通文本流生成,对问答与文件卡片完全透明。";
return (
<article
@@ -310,9 +288,6 @@ function SortableModelCard({
{model.supports_vision && (
<ModelCapabilityBadge icon={EyeIcon} label="视觉" className="bg-amber-500/10 text-amber-700 dark:text-amber-300" />
)}
{writeFileCompatActive && writeFileCompatEligible && (
<ModelCapabilityBadge icon={FileOutputIcon} label="文件流式兼容" className="bg-sky-500/10 text-sky-700 dark:text-sky-300" />
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge label={`全局默认:${model.default_reasoning_effort === "low" ? "低" : model.default_reasoning_effort === "medium" ? "中" : "Ultra"}`} className="bg-muted text-muted-foreground" />
)}
@@ -357,22 +332,6 @@ function SortableModelCard({
/>
</div>
)}
<div className="mt-2 flex items-center justify-between gap-4 rounded-lg border border-border px-3 py-2.5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
<span>文件流式兼容</span>
<Tag theme="default" variant="light" size="small">config.yaml</Tag>
{globalWriteFileCompatEnabled && <Tag theme="primary" variant="light" size="small">全局已开启</Tag>}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{writeFileCompatHint}</p>
</div>
<Switch
value={writeFileCompatActive}
loading={writeFileCompatPending}
disabled={globalWriteFileCompatEnabled || !writeFileCompatEligible}
onChange={(value) => onWriteFileCompatChange(Boolean(value))}
/>
</div>
</article>
);
}
@@ -487,9 +446,6 @@ export default function ModelManagementPage() {
const reorder = useReorderManagedModels();
const updateReasoningCapability =
useUpdateManagedModelReasoningEffortCapability();
const updateWriteFileTransport = useUpdateManagedModelWriteFileTransport();
const { data: writeFileCompatSettings } = useWriteFileCompatibilitySettings();
const globalWriteFileCompatEnabled = writeFileCompatSettings?.enabled ?? false;
const sourceModels = data?.models ?? [];
const orderedModels = useMemo(() => {
if (!pendingOrder || pendingOrder.length !== sourceModels.length) return sourceModels;
@@ -555,26 +511,6 @@ export default function ModelManagementPage() {
showGlobalMessage("error", error instanceof Error ? error.message : "保存全局思考强度失败");
}
};
const handleWriteFileCompatChange = async (
model: ManagedModel,
enabled: boolean,
) => {
try {
await updateWriteFileTransport.mutateAsync({
name: model.name,
writeFileTransport: enabled ? "buffered_gateway" : "native",
});
showGlobalMessage(
"success",
`模型“${model.display_name || model.name}”的文件流式兼容已${enabled ? "开启" : "关闭"},从下一次发起的问答开始生效。`,
);
} catch (error) {
showGlobalMessage(
"error",
error instanceof Error ? error.message : "保存文件流式兼容设置失败",
);
}
};
const dragEnabled = filter === "all" && !search.trim() && !reorder.isPending;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
@@ -701,11 +637,6 @@ export default function ModelManagementPage() {
updateReasoningCapability.isPending &&
updateReasoningCapability.variables?.name === model.name
}
globalWriteFileCompatEnabled={globalWriteFileCompatEnabled}
writeFileCompatPending={
updateWriteFileTransport.isPending &&
updateWriteFileTransport.variables?.name === model.name
}
onConfigure={() => setEditor(model)}
onDelete={() => setDeleteTarget(model)}
onReasoningCapabilityChange={(enabled) =>
@@ -714,9 +645,6 @@ export default function ModelManagementPage() {
onReasoningEffortChange={(effort) =>
void handleReasoningEffortChange(model, effort)
}
onWriteFileCompatChange={(enabled) =>
void handleWriteFileCompatChange(model, enabled)
}
/>
))}
</div>
+1 -1
View File
@@ -1489,7 +1489,7 @@ scheduler runs once per deployment. Per run:
- **可检测的模型失败**:`LLMErrorHandlingMiddleware` 在同一模型重试 3 次后**不抛异常**,而是返回兜底 `AIMessage` —— 现在给它打 `additional_kwargs[LLM_ERROR_MARKER="deerflow_llm_error"]=reason`(model_dump 保留,经 checkpointer 序列化仍在)。`is_llm_error_message(msg)` 据标记(回退按已知兜底文案正文)识别,返回 reason。
- **候选模型链**:保留真正模型故障后的自动换模,但“仅有思考 / 暂时无正文 / 空交付”不再判作模型故障,绝不据此切换用户选择的模型。`app/gateway/roundtable_model_fallback.py::fallback_model_chain(current)` 按 `config.yaml models[]` 顺序给出 `[当前模型, …其余]`(去重保序)。`reason_text(reason)` 翻译成简短中文。总开关 env `ROUNDTABLE_MODEL_FALLBACK`(默认开,`0/false/off` 关);`ROUNDTABLE_MODEL_FALLBACK_MAX`(0=试完全部)。
- **触发换模型的情形**:① 单轮 run 抛异常(start_run 失败 / idle 卡死 → `timeout`,其余 `exception`);② 兜底错误文案(`is_llm_error_message`)。交付无效、仅有思考、空串以及普通总控轮既无正文又无派活**均不触发换模**;唯一例外是无工具的最终收口请求已经完整结束仍无正文,此时结果客观不可交付,才继续自动换下一个模型。席位仍只认剥掉 `<think>` 后的可见正文(`deerflow.agents.roundtable_orchestrator.delivery`:`visible_seat_text` / `classify_seat_delivery`):前台 `_special_run` 对无效交付 yield `status:"invalid_delivery"` 且不广播;`GET /last-ai-message` 回 `{delivered, content, length, invalid_reason}`(思考-only 标 `thinking_only`,总控席位清单标 ⚠️)。后台引擎只把可见正文写入 `deliveries`,无效席位继续走既有未交付处理,不切换模型。Step3 单例(report/summary/dashboard/ingest/结构化)的产物是落盘文件,同样只对真模型错误换模型。
- **Step2 总控工具与可配置收口轮数**:普通总控使用真正白名单,只允许 `agent_orchestration` / `ask_clarification`;`write_file`、`present_files`、技能目录与文件/命令工具均不可见。`system_settings.json -> roundtable_room_limit.max_cycles`(默认 8,范围 2..100;管理员在设置弹窗「会商并发与收口」中修改)控制 recommend 模式到第几轮切换为显式 `finalize_only`:空工具白名单、注入已有有效席位全文、要求直接输出最终总结(失败席位如实标缺口),不能表现为“已停止”。一次会商启动时锁定当时值,运行中修改从下一次研讨生效;前台实时会商与后台作业读取同一设置。同一页的「会商文件流式兼容」(`write_file_compatibility_enabled`,默认关) 只作用于会商总控、席位、第三步和后台作业:全局文件流式兼容开着时会商一并使用;全局关着而本开关开着时,只有这些会商 run 改走 `buffered_gateway`。普通对话不读这个开关。chain / dag 的最终总结仍在既定席位完成后走同一纯正文路径;LangGraph 单轮 recursion limit 仍为 1000。
- **Step2 总控工具与可配置收口轮数**:普通总控使用真正白名单,只允许 `agent_orchestration` / `ask_clarification`;`write_file`、`present_files`、技能目录与文件/命令工具均不可见。`system_settings.json -> roundtable_room_limit.max_cycles`(默认 8,范围 2..100;管理员在设置弹窗「会商并发与收口」中修改)控制 recommend 模式到第几轮切换为显式 `finalize_only`:空工具白名单、注入已有有效席位全文、要求直接输出最终总结(失败席位如实标缺口),不能表现为“已停止”。一次会商启动时锁定当时值,运行中修改从下一次研讨生效;前台实时会商与后台作业读取同一设置。chain / dag 的最终总结仍在既定席位完成后走同一纯正文路径;LangGraph 单轮 recursion limit 仍为 1000。
- **后台**:`InProcessRoundtableGateway._run_turn_with_fallback(...)` 包住 `_run_agent_turn`,逐候选模型试;换模型落 `model_switch` 诊断、全失败落 `model_fallback_exhausted`。`run_leader/run_seat/run_report/run_summary/run_dashboard_data` 全走它(report/summary/dashboard 的产物自愈重试保留,并记住已切到的可用模型用于后续续写轮)。各 Turn/Result 带回 `switches: list[ModelSwitch]`,引擎 `_emit_switches` → `JobProgressWriter.model_switch(...)` 往研讨时间线插一条 `role="system"` 系统提示气泡。
- **前台**:`_special_run`/`_leader_run` stream 收尾后仅检测明确的模型错误标记;命中才 yield `{status:"model_switch", role, agent_name, failed_model, next_model, reason}` 帧并用下一个模型重新流式,思考-only/空正文不进入该分支;终态帧带 `model_used`。结构化抽取 `flow_extract.py` 的 round1 `model.astream` 也包了同款换模型循环(emit `event:"model_switch"` 帧)。
- **Step2 单席位隔离**:`engine._run_recommend/_run_chain/_run_dag` 的每个席位执行包 try/except —— 一个席位彻底失败(落 `seat_failed` 诊断 + 置空交付)不拖垮整批/整链/整个作业。
@@ -36,7 +36,6 @@ from app.gateway.routers._roundtable_seed import (
SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents,
)
from app.gateway.routers.multi_agent import _roundtable_file_stream_context
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD
from deerflow.agents.roundtable_orchestrator.delivery import classify_seat_delivery, visible_seat_text
from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json
@@ -366,7 +365,6 @@ async def _run_agent_turn(
# 被上轮会话 memory.json 带偏(臆造席位 / 直接干活)。详见该处注释。
"memory_injection_enabled": False,
"allowed_tools": allowed_tools,
**_roundtable_file_stream_context(),
},
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
@@ -224,12 +224,6 @@ class ReasoningEffortCapabilityRequest(BaseModel):
default_reasoning_effort: Literal["low", "medium", "xhigh"] | None = None
class WriteFileTransportToggleRequest(BaseModel):
"""Per-model 文件流式兼容 switch: off keeps native, on selects buffered_gateway."""
write_file_transport: Literal["native", "buffered_gateway"]
class ModelOrderRequest(BaseModel):
"""The complete ordered set of model stable names."""
@@ -319,39 +313,6 @@ def _validate_reasoning_capability(model: dict[str, Any], enabled: bool) -> None
)
# ``write_file_transport=buffered_gateway`` is implemented only by the patched
# OpenAI-compatible chat-completions classes (see deerflow.models.factory);
# every other driver is rejected there at construction time, so mirror the
# same constraint here with a clearer message.
_BUFFERED_GATEWAY_COMPATIBLE_USES = frozenset(
{
"langchain_openai:ChatOpenAI",
"deerflow.models.patched_openai:PatchedChatOpenAI",
}
)
def _validate_write_file_transport(model: dict[str, Any], transport: str) -> None:
if transport != WriteFileTransport.BUFFERED_GATEWAY.value:
return
if str(model.get("use") or "") not in _BUFFERED_GATEWAY_COMPATIBLE_USES:
raise HTTPException(
status_code=422,
detail=(
"write_file compatibility requires an OpenAI-compatible "
"langchain_openai:ChatOpenAI model"
),
)
if bool(model.get("use_responses_api")):
raise HTTPException(
status_code=422,
detail=(
"write_file compatibility requires the chat-completions API, "
"not use_responses_api"
),
)
async def _restore_capability(
store: ModelCapabilityStore,
model_name: str,
@@ -711,48 +672,6 @@ async def update_reasoning_effort_capability(
)
@router.put(
"/write-file-transport/{model_name:path}",
response_model=ModelAdminItem,
)
async def update_write_file_transport(
model_name: str,
body: WriteFileTransportToggleRequest,
request: Request,
) -> ModelAdminItem:
"""Toggle one model's buffered-gateway write_file compatibility in config.yaml.
The global system-settings switch keeps overriding every compatible model
while it is enabled; this per-model value becomes effective as soon as the
global switch is turned off.
"""
await _require_admin(request)
async with _MUTATION_LOCK:
path = _resolve_config_path()
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
if index is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
updated = copy.deepcopy(models[index])
_validate_write_file_transport(updated, body.write_file_transport)
updated["write_file_transport"] = body.write_file_transport
try:
ModelConfig.model_validate(updated)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid model configuration: {exc}") from exc
next_models = [*models]
next_models[index] = updated
await _save_models(request, raw_before, next_models)
capabilities = await _capabilities_by_name(request)
capability = capabilities.get(model_name, {})
return _to_admin_item(
updated,
supports_reasoning_effort=bool(capability.get("supports_reasoning_effort", False)),
default_reasoning_effort=str(capability.get("default_reasoning_effort") or "xhigh"),
)
@router.put("/{model_name:path}", response_model=ModelAdminItem)
async def update_model_configuration(model_name: str, body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
admin = await _require_admin(request)
@@ -742,7 +742,6 @@ def _run_payload(
cancel_on_disconnect: bool = False,
extra_context: dict[str, Any] | None = None,
files: list[dict[str, Any]] | None = None,
roundtable_file_stream: bool = False,
) -> dict[str, Any]:
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
@@ -820,20 +819,9 @@ def _run_payload(
# 供 read_peer_delivery 工具按需读取)。只进 context dict,不进 prompt → 不调工具就不耗 token。
if extra_context:
payload["context"].update(extra_context)
if roundtable_file_stream:
payload["context"].update(_roundtable_file_stream_context())
return payload
def _roundtable_file_stream_context() -> dict[str, bool]:
"""会商专用文件流式兼容。关闭时不写 context,本轮仍只看全局开关。"""
from deerflow.config.system_settings import get_roundtable_room_limit_settings
if get_roundtable_room_limit_settings().write_file_compatibility_enabled:
return {"roundtable_write_file_compatibility": True}
return {}
# ---------------------------------------------------------------------------
# POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
# ---------------------------------------------------------------------------
@@ -1709,7 +1697,6 @@ async def _leader_run(
cancel_on_disconnect=True,
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
files=files,
roundtable_file_stream=True,
)
body = _mk_body(current_model)
@@ -1993,7 +1980,6 @@ async def _special_run(
),
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
files=files,
roundtable_file_stream=True,
)
t_payload_built = time.perf_counter()
@@ -456,15 +456,10 @@ class ConversationContextResponse(BaseModel):
default=3,
description="Fixed number of real user turns retained by the sliding window",
)
use_question_as_title: bool = Field(
default=False,
description="Whether the first user question is the final title, without post-answer title generation",
)
class ConversationContextUpdateRequest(BaseModel):
recent_turn_window_enabled: bool | None = None
use_question_as_title: bool | None = None
recent_turn_window_enabled: bool
class RoundtableRoomLimitResponse(BaseModel):
@@ -472,7 +467,6 @@ class RoundtableRoomLimitResponse(BaseModel):
max_rooms: int = 3
max_hold_minutes: int = 30
max_cycles: int = 8
write_file_compatibility_enabled: bool = False
class RoundtableRoomLimitUpdateRequest(BaseModel):
@@ -480,7 +474,6 @@ class RoundtableRoomLimitUpdateRequest(BaseModel):
max_rooms: int | None = Field(default=None, ge=1, le=20)
max_hold_minutes: int | None = Field(default=None, ge=1, le=1440)
max_cycles: int | None = Field(default=None, ge=2, le=100)
write_file_compatibility_enabled: bool | None = None
class OrdinaryKnowledgeRetrievalResponse(BaseModel):
@@ -563,7 +556,6 @@ def _conversation_context_response(settings: ConversationContextSettings) -> Con
return ConversationContextResponse(
recent_turn_window_enabled=settings.recent_turn_window_enabled,
recent_turns=3,
use_question_as_title=settings.use_question_as_title,
)
@@ -581,15 +573,13 @@ async def update_conversation_context_setting(
"""Enable or disable the fixed recent-three-turn window. Admin only."""
await _require_admin(request)
current = load_system_settings()
updated_fields = body.model_dump(exclude_unset=True, exclude_none=True)
if not updated_fields:
raise HTTPException(status_code=422, detail="至少需要提供一个上下文设置项")
updated = current.conversation_context.model_copy(update=updated_fields)
updated = current.conversation_context.model_copy(
update={"recent_turn_window_enabled": body.recent_turn_window_enabled}
)
saved = save_system_settings(current.model_copy(update={"conversation_context": updated}))
logger.info(
"Conversation runtime settings changed: recent_turn_window=%s use_question_as_title=%s",
"Conversation recent-turn window changed: enabled=%s retained_turns=3",
saved.conversation_context.recent_turn_window_enabled,
saved.conversation_context.use_question_as_title,
)
return _conversation_context_response(saved.conversation_context)
@@ -600,7 +590,6 @@ def _roundtable_room_limit_response(settings: RoundtableRoomLimitSettings) -> Ro
max_rooms=settings.max_rooms,
max_hold_minutes=settings.max_hold_minutes,
max_cycles=settings.max_cycles,
write_file_compatibility_enabled=settings.write_file_compatibility_enabled,
)
@@ -627,17 +616,14 @@ async def update_roundtable_room_limit_setting(
updates["max_hold_minutes"] = body.max_hold_minutes
if body.max_cycles is not None:
updates["max_cycles"] = body.max_cycles
if body.write_file_compatibility_enabled is not None:
updates["write_file_compatibility_enabled"] = body.write_file_compatibility_enabled
updated = current.roundtable_room_limit.model_copy(update=updates)
saved = save_system_settings(current.model_copy(update={"roundtable_room_limit": updated}))
logger.info(
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s max_cycles=%s write_file_compatibility=%s",
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s max_cycles=%s",
saved.roundtable_room_limit.enabled,
saved.roundtable_room_limit.max_rooms,
saved.roundtable_room_limit.max_hold_minutes,
saved.roundtable_room_limit.max_cycles,
saved.roundtable_room_limit.write_file_compatibility_enabled,
)
return _roundtable_room_limit_response(saved.roundtable_room_limit)
@@ -577,8 +577,6 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"roundtable_peer_deliveries",
# 岗位会商席位 / 方案总结:每轮 outputs 下 write_file 上限(行动规划不传此键)。
"position_artifact_cap",
# 会商专用文件流式兼容。只由会商 run 写入;普通对话不带此键,仍只看全局开关。
"roundtable_write_file_compatibility",
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
"summarization_enabled",
+3 -5
View File
@@ -87,7 +87,6 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
write_file_transport: buffered_gateway
- name: MiniMax-M2.7-fash
display_name: MiniMax-M2.7-fash
use: langchain_openai:ChatOpenAI
@@ -108,6 +107,9 @@ models:
base_url: https://ai.hzfzlz.com/v1
request_timeout: 600.0
max_retries: 2
# 不带 max_tokens 时该中转站按自身小默认值截断输出(实测 ~64 token),
# 检索词 JSON 会被截断导致解析失败走兜底。
# write_file 写整页 HTML 时常超过 4096,截断后只剩 path,沙箱无法流式预览。
max_tokens: 32768
stream_usage: true
supports_thinking: false
@@ -123,7 +125,6 @@ models:
stream_usage: true
supports_thinking: false
supports_vision: false
write_file_transport: native
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
@@ -194,9 +195,6 @@ models:
temperature: 1.0
supports_vision: false
supports_thinking: true
# 不带 max_tokens 时该中转站按自身小默认值截断输出(实测 ~64 token),
# 检索词 JSON 会被截断导致解析失败走兜底。
# write_file 写整页 HTML 时常超过 4096,截断后只剩 path,沙箱无法流式预览。
# - name: deepseek-v4-flash
# display_name: DeepSeek V4 Flash
# use: langchain_openai:ChatOpenAI
@@ -826,8 +826,6 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# create_chat_model(force_disable_thinking=...) so enable_thinking=false is
# actually injected. Default off → no behavior change for normal chats.
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
# Roundtable runs opt in explicitly. Missing/false keeps the global switch.
write_file_compatibility = True if cfg.get("roundtable_write_file_compatibility") else None
reasoning_effort = cfg.get("reasoning_effort", None)
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
is_plan_mode = cfg.get("is_plan_mode", False)
@@ -1027,7 +1025,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
setup_tools = [setup_writing] if writing_setup_quick else [setup_writing, ask_clarification_tool]
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=setup_tools,
middleware=_build_setup_middlewares(
config,
@@ -1064,7 +1062,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
)
)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=setup_tools,
middleware=_build_setup_middlewares(
config,
@@ -1089,7 +1087,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=get_available_tools(
include_mcp=not classic_knowledge_space_strict,
model_name=model_name,
@@ -1099,7 +1097,6 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
allowed_tools=requested_allowed_tools,
allow_agent_orchestration=agent_id in _COORDINATOR_AGENT_IDS,
app_config=resolved_app_config,
write_file_compatibility=write_file_compatibility,
)
+ [setup_agent],
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
@@ -1133,7 +1130,6 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
allowed_tools=requested_allowed_tools,
allow_agent_orchestration=agent_id in _COORDINATOR_AGENT_IDS,
app_config=resolved_app_config,
write_file_compatibility=write_file_compatibility,
)
)
if agent_id == FORCED_RESEARCH_AGENT_ID:
@@ -1247,7 +1243,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=default_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
system_prompt=system_prompt,
@@ -12,7 +12,6 @@ from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langgraph.runtime import Runtime
from deerflow.config.system_settings import get_conversation_context_settings
from deerflow.config.title_config import get_title_config
from deerflow.models import create_chat_model
from deerflow.utils.time import now_iso
@@ -210,15 +209,14 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
return self._fallback_title(user_msg)
def _should_set_provisional_title(self, state: TitleMiddlewareState) -> bool:
"""Check if we should set an instant, question-derived title.
"""Check if we should set an instant, question-derived placeholder title.
Runs in ``before_model`` so the thread shows a sensible title the moment
the user sends their first message — eliminating the "untitled" window.
When the administrator selects question-derived titles, this also works
when LLM title generation is disabled because no title model is needed.
the user sends their first message — eliminating the "untitled" window
while the (possibly long) first answer streams. No LLM call, no latency.
"""
config = self._get_title_config()
if not config.enabled and not get_conversation_context_settings().use_question_as_title:
if not config.enabled:
return False
# Already has a title (provisional or final) — nothing to do.
@@ -335,13 +333,8 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
if not user_msg.strip():
return None
title = self._validated_fallback_title(state, user_msg)
use_question_as_title = get_conversation_context_settings().use_question_as_title
logger.debug(
"TitleMiddleware: set %s title %r before first model call",
"final question-derived" if use_question_as_title else "provisional question-derived",
title,
)
return {"title": title, "title_provisional": not use_question_as_title}
logger.debug("TitleMiddleware: set provisional title %r before first model call", title)
return {"title": title, "title_provisional": True}
def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None:
"""Generate a local fallback title without blocking on an LLM call."""
@@ -576,13 +576,6 @@ class ConversationContextSettings(BaseModel):
default=False,
description="Whether model requests use the fixed recent-three-turn sliding window.",
)
use_question_as_title: bool = Field(
default=False,
description=(
"Whether the first user question is used as the final conversation title, "
"skipping post-answer LLM title generation."
),
)
model_config = ConfigDict(extra="ignore")
@@ -636,12 +629,22 @@ class RoundtableRoomLimitSettings(BaseModel):
"The value is captured when an orchestration starts."
),
)
write_file_compatibility_enabled: bool = Field(
model_config = ConfigDict(extra="ignore")
class ConversationContextSettings(BaseModel):
"""Deployment-wide short-context policy for chat model requests.
The recent-turn window is deliberately opt-in. When enabled, the lead
agent keeps the persisted conversation intact but sends only the latest
three real user turns (plus their assistant/tool continuations) to the
model. This avoids an extra summarization-model call while preserving the
complete transcript for the UI and checkpoint recovery.
"""
recent_turn_window_enabled: bool = Field(
default=False,
description=(
"When true, roundtable leader/seat/step-3 runs use buffered-gateway "
"write_file streaming. Ordinary chat keeps the global switch."
),
description="Whether model requests use the fixed recent-three-turn sliding window.",
)
model_config = ConfigDict(extra="ignore")
@@ -711,6 +714,7 @@ class SystemSettings(BaseModel):
write_file_compatibility: WriteFileCompatibilitySettings = Field(
default_factory=WriteFileCompatibilitySettings
)
conversation_context: ConversationContextSettings = Field(default_factory=ConversationContextSettings)
roundtable_room_limit: RoundtableRoomLimitSettings = Field(default_factory=RoundtableRoomLimitSettings)
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
strategy_projects: StrategyProjectSettings = Field(default_factory=StrategyProjectSettings)
@@ -847,6 +851,15 @@ def get_roundtable_room_limit_settings() -> RoundtableRoomLimitSettings:
return RoundtableRoomLimitSettings()
def get_conversation_context_settings() -> ConversationContextSettings:
"""Return the admin-controlled recent-turn policy, failing closed."""
try:
return load_system_settings().conversation_context
except Exception:
logger.debug("Failed to read conversation context setting; keeping the recent-turn window disabled", exc_info=True)
return ConversationContextSettings()
def save_system_settings(settings: SystemSettings) -> SystemSettings:
"""Persist the settings document atomically, returning the saved value."""
global _settings_cache_key, _settings_cache_value
@@ -136,7 +136,7 @@ def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_con
model_settings_from_config["stream_usage"] = True
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, force_disable_thinking: bool = False, write_file_compatibility: bool | None = None, **kwargs) -> BaseChatModel:
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, force_disable_thinking: bool = False, **kwargs) -> BaseChatModel:
"""Create a chat model instance from the config.
Args:
@@ -187,17 +187,10 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
},
)
configured_write_file_transport = model_config.write_file_transport
# ``None`` follows the global switch, so a roundtable run with its own
# switch off still uses compatibility when the global switch is on.
# ``True`` is the roundtable opt-in used when the global switch is off.
compatibility_enabled = (
get_write_file_compatibility_settings().enabled
if write_file_compatibility is None
else write_file_compatibility
)
runtime_write_file_compatibility = get_write_file_compatibility_settings()
effective_write_file_transport = (
WriteFileTransport.BUFFERED_GATEWAY
if compatibility_enabled
if runtime_write_file_compatibility.enabled
else configured_write_file_transport
)
if model_config.tool_call_transport.value != "native":
@@ -289,8 +289,6 @@ class SubagentExecutor:
thread_data: ThreadDataState | None = None,
thread_id: str | None = None,
trace_id: str | None = None,
*,
write_file_compatibility: bool | None = None,
):
"""Initialize the executor.
@@ -319,7 +317,6 @@ class SubagentExecutor:
self.sandbox_state = sandbox_state
self.thread_data = thread_data
self.thread_id = thread_id
self.write_file_compatibility = True if write_file_compatibility else None
# Generate trace_id if not provided (for top-level calls)
self.trace_id = trace_id or str(uuid.uuid4())[:8]
@@ -337,12 +334,7 @@ class SubagentExecutor:
app_config = self.app_config or get_app_config()
if self.model_name is None:
self.model_name = resolve_subagent_model_name(self.config, self.parent_model, app_config=app_config)
model = create_chat_model(
name=self.model_name,
thinking_enabled=False,
app_config=app_config,
write_file_compatibility=self.write_file_compatibility,
)
model = create_chat_model(name=self.model_name, thinking_enabled=False, app_config=app_config)
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
@@ -157,8 +157,6 @@ async def task_tool(
effective_model = resolve_subagent_model_name(config, parent_model, app_config=resolved_app_config)
# Subagents should not have subagent tools enabled (prevent recursive nesting)
parent_context = runtime.context if runtime is not None and isinstance(getattr(runtime, "context", None), dict) else {}
roundtable_file_stream = bool(parent_context.get("roundtable_write_file_compatibility"))
available_tools_kwargs = {
"model_name": effective_model,
"groups": parent_tool_groups,
@@ -166,8 +164,6 @@ async def task_tool(
}
if resolved_app_config is not None:
available_tools_kwargs["app_config"] = resolved_app_config
if roundtable_file_stream:
available_tools_kwargs["write_file_compatibility"] = True
tools = get_available_tools(**available_tools_kwargs)
# Create executor
@@ -182,8 +178,6 @@ async def task_tool(
}
if resolved_app_config is not None:
executor_kwargs["app_config"] = resolved_app_config
if roundtable_file_stream:
executor_kwargs["write_file_compatibility"] = True
executor = SubagentExecutor(**executor_kwargs)
# Start background execution (always async to prevent blocking)
@@ -71,7 +71,6 @@ def get_available_tools(
allowed_tools: list[str] | None = None,
allow_agent_orchestration: bool = False,
app_config: AppConfig | None = None,
write_file_compatibility: bool | None = None,
) -> list[BaseTool]:
"""Get all available tools from config.
@@ -106,8 +105,6 @@ def get_available_tools(
# requests entirely inside PatchedChatOpenAI.
global_write_file_compatibility_enabled = (
get_write_file_compatibility_settings().enabled
if write_file_compatibility is None
else write_file_compatibility
)
write_renderer_enabled = (
not global_write_file_compatibility_enabled
@@ -4543,37 +4543,6 @@ def test_factory_global_switch_overrides_every_compatible_model(
assert second.write_file_transport == "buffered_gateway"
def test_roundtable_override_enables_buffered_gateway_while_global_is_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
model_factory,
"get_write_file_compatibility_settings",
lambda: SimpleNamespace(enabled=False),
)
config = AppConfig.model_construct(
models=[
ModelConfig(
name="native-model",
use="langchain_openai:ChatOpenAI",
model="native-model",
api_key="test-key",
base_url=None,
)
]
)
ordinary = model_factory.create_chat_model("native-model", app_config=config)
roundtable = model_factory.create_chat_model(
"native-model",
app_config=config,
write_file_compatibility=True,
)
assert ordinary.write_file_transport == "native"
assert roundtable.write_file_transport == "buffered_gateway"
def test_factory_rejects_bridge_for_non_patched_provider(monkeypatch: pytest.MonkeyPatch) -> None:
config = AppConfig.model_construct(
models=[
@@ -215,121 +215,6 @@ def test_reasoning_capability_route_precedes_generic_model_update_and_accepts_sl
]
def test_write_file_transport_route_precedes_generic_model_update() -> None:
scope = {
"type": "http",
"method": "PUT",
"path": "/api/admin/models/write-file-transport/zai-org/GLM-5-FP8",
}
matching_paths = [route.path for route in router.router.routes if route.matches(scope)[0] is Match.FULL]
assert matching_paths[:2] == [
"/api/admin/models/write-file-transport/{model_name:path}",
"/api/admin/models/{model_name:path}",
]
def _admin_request():
return SimpleNamespace(
state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
app=SimpleNamespace(state=SimpleNamespace()),
)
@pytest.mark.asyncio
async def test_write_file_transport_toggle_persists_native_and_buffered_gateway(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: flash
use: langchain_openai:ChatOpenAI
model: flash
""",
encoding="utf-8",
)
saved: dict[str, object] = {}
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
saved["raw_before"] = raw_before
saved["models"] = models
return models
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "_save_models", save_models)
enabled = await router.update_write_file_transport(
"flash",
router.WriteFileTransportToggleRequest(write_file_transport="buffered_gateway"),
_admin_request(),
)
assert enabled.write_file_transport == "buffered_gateway"
assert saved["models"] == [
{
"name": "flash",
"use": "langchain_openai:ChatOpenAI",
"model": "flash",
"write_file_transport": "buffered_gateway",
}
]
disabled = await router.update_write_file_transport(
"flash",
router.WriteFileTransportToggleRequest(write_file_transport="native"),
_admin_request(),
)
assert disabled.write_file_transport == "native"
assert saved["models"][0]["write_file_transport"] == "native"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("use", "use_responses_api"),
[
("langchain_anthropic:ChatAnthropic", None),
("langchain_openai:ChatOpenAI", True),
],
)
async def test_write_file_transport_rejects_incompatible_models(
use: str,
use_responses_api: bool | None,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = f"""models:
- name: incompatible
use: {use}
model: incompatible
"""
if use_responses_api:
original = original.replace(
" model: incompatible\n",
" model: incompatible\n use_responses_api: true\n",
)
config_path.write_text(original, encoding="utf-8")
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
async def fail_save_models(_request, _raw_before, _models):
raise AssertionError("incompatible models must not be persisted")
monkeypatch.setattr(router, "_save_models", fail_save_models)
with pytest.raises(HTTPException) as exc_info:
await router.update_write_file_transport(
"incompatible",
router.WriteFileTransportToggleRequest(write_file_transport="buffered_gateway"),
_admin_request(),
)
assert exc_info.value.status_code == 422
assert config_path.read_text(encoding="utf-8") == original
def _reasoning_request(store, *, config: AppConfig | None = None):
return SimpleNamespace(
state=SimpleNamespace(
@@ -745,7 +745,6 @@ async def test_admin_can_change_roundtable_room_limit_without_restart(
assert ss.get_roundtable_room_limit_settings().max_rooms == 3
assert ss.get_roundtable_room_limit_settings().max_hold_minutes == 30
assert ss.get_roundtable_room_limit_settings().max_cycles == 8
assert ss.get_roundtable_room_limit_settings().write_file_compatibility_enabled is False
saved = await ss_router.update_roundtable_room_limit_setting(
ss_router.RoundtableRoomLimitUpdateRequest(
@@ -763,36 +762,6 @@ async def test_admin_can_change_roundtable_room_limit_without_restart(
assert ss.load_system_settings().roundtable_room_limit.max_rooms == 5
assert ss.load_system_settings().roundtable_room_limit.max_hold_minutes == 45
assert ss.load_system_settings().roundtable_room_limit.max_cycles == 12
assert saved.write_file_compatibility_enabled is False
@pytest.mark.asyncio
async def test_roundtable_file_stream_is_only_attached_to_roundtable_runs(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
base = dict(
agent_name="roundtable-seat",
model_name="test-model",
thread_id="thread-1",
new_message="test",
excluded_tools=[],
skill_stop_names=[],
)
before = _run_payload(**base, roundtable_file_stream=True)
assert "roundtable_write_file_compatibility" not in before["context"]
saved = await ss_router.update_roundtable_room_limit_setting(
ss_router.RoundtableRoomLimitUpdateRequest(write_file_compatibility_enabled=True),
SimpleNamespace(),
)
assert saved.write_file_compatibility_enabled is True
opted_in = _run_payload(**base, roundtable_file_stream=True)
assert opted_in["context"]["roundtable_write_file_compatibility"] is True
ordinary = _run_payload(**base)
assert "roundtable_write_file_compatibility" not in ordinary["context"]
@pytest.mark.asyncio
@@ -114,46 +114,6 @@ def _title_config() -> TitleConfig:
)
def test_admin_question_title_switch_finalizes_question_before_first_model_call(monkeypatch) -> None:
monkeypatch.setattr(
title_middleware_module,
"get_conversation_context_settings",
lambda: SimpleNamespace(use_question_as_title=True),
)
middleware = TitleMiddleware(
title_config=_title_config().model_copy(update={"enabled": False})
)
state = {
"messages": [SimpleNamespace(type="human", content="请帮我分析这份合同")],
}
result = middleware.before_model(state, runtime=SimpleNamespace())
assert result == {
"title": "请帮我分析这份合同",
"title_provisional": False,
}
def test_admin_question_title_switch_off_keeps_async_title_generation(monkeypatch) -> None:
monkeypatch.setattr(
title_middleware_module,
"get_conversation_context_settings",
lambda: SimpleNamespace(use_question_as_title=False),
)
middleware = TitleMiddleware(title_config=_title_config())
state = {
"messages": [SimpleNamespace(type="human", content="请帮我分析这份合同")],
}
result = middleware.before_model(state, runtime=SimpleNamespace())
assert result == {
"title": "请帮我分析这份合同",
"title_provisional": True,
}
@pytest.mark.asyncio
async def test_finalize_thread_title_updates_checkpoint_and_display_name(monkeypatch) -> None:
async def _fake_generate(self, state):
+1
View File
@@ -493,3 +493,4 @@ task_deeplink:
taskDirection: "正向引导 + 风险预警"
taskName: "任务01:暑期文旅舆情综合研判"
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
taskType: 1