代码提交
This commit is contained in:
1 parent
f2e81d8875
commit
cebc44ff82
4 files changed
+167
-14
No files matched your search
@@ -244,6 +244,11 @@ const TERMINAL_RUN_STATUSES = new Set([
|
|||||||
// for a while, then reconcile it from the authoritative run/state endpoints.
|
// for a while, then reconcile it from the authoritative run/state endpoints.
|
||||||
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
|
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
|
||||||
const STREAM_RECONCILIATION_POLL_MS = 5_000;
|
const STREAM_RECONCILIATION_POLL_MS = 5_000;
|
||||||
|
// A run can legitimately be quiet while a long tool call is in progress, so
|
||||||
|
// reconnect more conservatively than the terminal-status check above. The
|
||||||
|
// reconnection only replaces the local SSE reader; the backend run keeps going.
|
||||||
|
const STREAM_RECONNECT_IDLE_MS = 30_000;
|
||||||
|
const STREAM_RECONNECT_MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
async function fetchThreadTitleRefreshState(
|
async function fetchThreadTitleRefreshState(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
@@ -466,9 +471,15 @@ export function useThreadStream({
|
|||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const lastStreamActivityAtRef = useRef(0);
|
const lastStreamActivityAtRef = useRef(0);
|
||||||
const streamReconciliationInFlightRef = useRef(false);
|
const streamReconciliationInFlightRef = useRef(false);
|
||||||
|
const streamReconnectAttemptsRef = useRef(0);
|
||||||
|
const streamReconnectToastShownRef = useRef(false);
|
||||||
|
const streamReconnectFailureToastShownRef = useRef(false);
|
||||||
|
|
||||||
const markStreamActivity = useCallback(() => {
|
const markStreamActivity = useCallback(() => {
|
||||||
lastStreamActivityAtRef.current = Date.now();
|
lastStreamActivityAtRef.current = Date.now();
|
||||||
|
streamReconnectAttemptsRef.current = 0;
|
||||||
|
streamReconnectToastShownRef.current = false;
|
||||||
|
streamReconnectFailureToastShownRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const listeners = useRef({
|
const listeners = useRef({
|
||||||
@@ -761,13 +772,6 @@ export function useThreadStream({
|
|||||||
threadStreamRef.current = thread;
|
threadStreamRef.current = thread;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!thread.isLoading) {
|
|
||||||
// A normal terminal event reached the browser. A later run may use the
|
|
||||||
// same thread, so it must be eligible for recovery again.
|
|
||||||
streamReconciliationInFlightRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||||
if (!activeThreadId || typeof window === "undefined") {
|
if (!activeThreadId || typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
@@ -776,7 +780,7 @@ export function useThreadStream({
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let refreshTimer: number | null = null;
|
let refreshTimer: number | null = null;
|
||||||
|
|
||||||
const reconcileCompletedRun = async () => {
|
const reconcileRun = async () => {
|
||||||
if (
|
if (
|
||||||
disposed ||
|
disposed ||
|
||||||
streamReconciliationInFlightRef.current ||
|
streamReconciliationInFlightRef.current ||
|
||||||
@@ -793,7 +797,51 @@ export function useThreadStream({
|
|||||||
try {
|
try {
|
||||||
const client = getAPIClient(isMock);
|
const client = getAPIClient(isMock);
|
||||||
const run = await client.runs.get(activeThreadId, runId);
|
const run = await client.runs.get(activeThreadId, runId);
|
||||||
if (disposed || !TERMINAL_RUN_STATUSES.has(run.status)) {
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
|
||||||
|
// ``useStream`` retries one failed connection on its own. If that
|
||||||
|
// reader quietly stalls, however, its internal queue never gets a
|
||||||
|
// terminal error and the page remains frozen despite the backend
|
||||||
|
// continuing the run. Abort only the local reader, then join the
|
||||||
|
// same run so no work or tokens are lost server-side.
|
||||||
|
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
|
||||||
|
if (!streamReconnectFailureToastShownRef.current) {
|
||||||
|
streamReconnectFailureToastShownRef.current = true;
|
||||||
|
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamReconnectAttemptsRef.current += 1;
|
||||||
|
// Avoid enqueueing another reconnect before this new reader has had
|
||||||
|
// enough time to receive its first event. A genuine event resets
|
||||||
|
// the attempt counter through ``markStreamActivity``.
|
||||||
|
lastStreamActivityAtRef.current = Date.now();
|
||||||
|
if (!streamReconnectToastShownRef.current) {
|
||||||
|
streamReconnectToastShownRef.current = true;
|
||||||
|
toast.info("回答连接出现波动,正在自动恢复…");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = threadStreamRef.current;
|
||||||
|
void Promise.resolve(stream.stop())
|
||||||
|
.catch(() => {
|
||||||
|
// Stopping a stale local reader is best-effort; joining is still
|
||||||
|
// useful if the SDK already considers the stream stopped.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (disposed || threadIdRef.current !== activeThreadId) return;
|
||||||
|
void stream.joinStream(runId).catch(() => {
|
||||||
|
// The status poll will either reconcile a terminal run or
|
||||||
|
// schedule the next bounded reconnect attempt.
|
||||||
|
});
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,13 +849,17 @@ export function useThreadStream({
|
|||||||
// receive any more useful events. Load the checkpoint directly before
|
// receive any more useful events. Load the checkpoint directly before
|
||||||
// resetting the SDK stream; this preserves the normal onFinish side
|
// resetting the SDK stream; this preserves the normal onFinish side
|
||||||
// effects (e.g. marking a position delivery complete) even when the
|
// effects (e.g. marking a position delivery complete) even when the
|
||||||
// browser missed the terminal event.
|
// browser missed the terminal event or already reported a transport
|
||||||
|
// error and dropped ``isLoading``.
|
||||||
const finalState = await client.threads.getState(activeThreadId);
|
const finalState = await client.threads.getState(activeThreadId);
|
||||||
if (disposed || !threadStreamRef.current.isLoading) {
|
if (disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
||||||
|
streamReconnectAttemptsRef.current = 0;
|
||||||
|
streamReconnectToastShownRef.current = false;
|
||||||
|
streamReconnectFailureToastShownRef.current = false;
|
||||||
if (run.status === "success") {
|
if (run.status === "success") {
|
||||||
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
@@ -836,7 +888,7 @@ export function useThreadStream({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const interval = window.setInterval(
|
const interval = window.setInterval(
|
||||||
() => void reconcileCompletedRun(),
|
() => void reconcileRun(),
|
||||||
STREAM_RECONCILIATION_POLL_MS,
|
STREAM_RECONCILIATION_POLL_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -64,3 +64,6 @@ ROUNDTABLE_JOB_GATEWAY=inprocess
|
|||||||
DEEPSEEK_CHAT_API_KEY=sk-686b7bc000be4b7cbed6dccb3897c411
|
DEEPSEEK_CHAT_API_KEY=sk-686b7bc000be4b7cbed6dccb3897c411
|
||||||
GLM5_FP8_API_KEY=modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
GLM5_FP8_API_KEY=modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||||
SILICONFLOW_API_KEY=sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
SILICONFLOW_API_KEY=sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||||
|
|
||||||
|
# WeKnora admin credentials (DeerFlow server-side iframe SSO proxy)
|
||||||
|
WEKNORA_ADMIN_PASSWORD=1qaz@WSX
|
||||||
@@ -364,7 +364,40 @@ async def run_agent(
|
|||||||
else:
|
else:
|
||||||
await run_manager.set_status(run_id, RunStatus.interrupted)
|
await run_manager.set_status(run_id, RunStatus.interrupted)
|
||||||
else:
|
else:
|
||||||
await run_manager.set_status(run_id, RunStatus.success)
|
# LLMErrorHandlingMiddleware intentionally converts an exhausted
|
||||||
|
# provider failure into a user-safe AIMessage instead of raising.
|
||||||
|
# That keeps a malformed upstream response out of the SSE transport,
|
||||||
|
# but previously made this worker record the run as ``success``.
|
||||||
|
# The UI consequently had no error event to show and looked as if
|
||||||
|
# generation had silently stopped halfway through. Inspect the
|
||||||
|
# terminal checkpoint before declaring success so the persisted run
|
||||||
|
# state and the user-visible SSE outcome stay consistent.
|
||||||
|
terminal_llm_error = await _terminal_llm_error_reason(
|
||||||
|
checkpointer=checkpointer,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
if terminal_llm_error is not None:
|
||||||
|
error_message = "本轮模型生成未能完成,已保留当前对话内容,请稍后重试。"
|
||||||
|
logger.warning(
|
||||||
|
"Run %s ended with LLM fallback message (reason=%s)",
|
||||||
|
run_id,
|
||||||
|
terminal_llm_error,
|
||||||
|
)
|
||||||
|
await run_manager.set_status(
|
||||||
|
run_id,
|
||||||
|
RunStatus.error,
|
||||||
|
error=error_message,
|
||||||
|
)
|
||||||
|
await bridge.publish(
|
||||||
|
run_id,
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"message": error_message,
|
||||||
|
"name": "ModelGenerationError",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await run_manager.set_status(run_id, RunStatus.success)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
action = record.abort_action
|
action = record.abort_action
|
||||||
@@ -484,6 +517,65 @@ async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_nam
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _terminal_llm_error_reason(
|
||||||
|
*,
|
||||||
|
checkpointer: Any | None,
|
||||||
|
thread_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Return the terminal LLM fallback reason, if this run ended in one.
|
||||||
|
|
||||||
|
``LLMErrorHandlingMiddleware`` persists its fallback as an AI message with
|
||||||
|
an internal marker. A normal agent graph treats that message as a valid
|
||||||
|
result and completes without an exception, so the worker must check the
|
||||||
|
saved conversation rather than relying solely on ``astream`` raising.
|
||||||
|
"""
|
||||||
|
if checkpointer is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
checkpoint_config = {
|
||||||
|
"configurable": {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"checkpoint_ns": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkpoint_tuple = await _call_checkpointer_method(
|
||||||
|
checkpointer,
|
||||||
|
"aget_tuple",
|
||||||
|
"get_tuple",
|
||||||
|
checkpoint_config,
|
||||||
|
)
|
||||||
|
if checkpoint_tuple is None:
|
||||||
|
return None
|
||||||
|
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||||
|
channel_values = checkpoint.get("channel_values", {})
|
||||||
|
messages = channel_values.get("messages", []) if isinstance(channel_values, dict) else []
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Walk backwards to the latest assistant message. Tool/human messages
|
||||||
|
# may be appended around it by middleware, but an older fallback should
|
||||||
|
# never turn a later successful response into an error.
|
||||||
|
for message in reversed(messages):
|
||||||
|
message_type = message.get("type") if isinstance(message, dict) else getattr(message, "type", None)
|
||||||
|
if message_type not in {"ai", "assistant"}:
|
||||||
|
continue
|
||||||
|
from deerflow.agents.middlewares.llm_error_handling_middleware import (
|
||||||
|
is_llm_error_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return is_llm_error_message(message)
|
||||||
|
except Exception:
|
||||||
|
# A status check must never turn a completed answer into a failed run
|
||||||
|
# merely because a custom checkpointer cannot expose its latest tuple.
|
||||||
|
logger.debug(
|
||||||
|
"Could not inspect terminal LLM state for thread %s",
|
||||||
|
thread_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _rollback_to_pre_run_checkpoint(
|
async def _rollback_to_pre_run_checkpoint(
|
||||||
*,
|
*,
|
||||||
checkpointer: Any,
|
checkpointer: Any,
|
||||||
|
|||||||
+7
-1
@@ -3,7 +3,13 @@ from typing import Literal
|
|||||||
from langchain.tools import tool
|
from langchain.tools import tool
|
||||||
|
|
||||||
|
|
||||||
@tool("ask_clarification", parse_docstring=True, return_direct=True)
|
# ClarificationMiddleware owns the stop behaviour: it returns a Command that
|
||||||
|
# writes the ToolMessage and explicitly routes to END. ``return_direct=True``
|
||||||
|
# makes the prebuilt tool node short-circuit before that Command update is
|
||||||
|
# checkpointed in some LangGraph versions, leaving an assistant tool call
|
||||||
|
# without its matching ToolMessage. The next user reply then fails provider
|
||||||
|
# validation with "tool_calls must be followed by tool messages".
|
||||||
|
@tool("ask_clarification", parse_docstring=True, return_direct=False)
|
||||||
def ask_clarification_tool(
|
def ask_clarification_tool(
|
||||||
question: str,
|
question: str,
|
||||||
clarification_type: Literal[
|
clarification_type: Literal[
|
||||||
|
|||||||
Reference in new issue
Block a user