Compare commits

..

No commits in common. "6c0a32330c787312e2aa04c098ac18f7ffb6ebfc" and "c4f5ef8c56209aa344f7d57431ae29cc2c61c05e" have entirely different histories.

10 changed files with 24 additions and 195 deletions

View File

@ -37,19 +37,14 @@ class SubAgentModelCallError(RuntimeError):
"""子智能体模型请求失败。 """子智能体模型请求失败。
received_any=False请求阶段失败未收到任何文本/思考/工具调用可重试 received_any=False请求阶段失败未收到任何文本/思考/工具调用可重试
received_any=True已开始收到流内容后断开输出期间断开 received_any=True已开始收到流内容后断开输出期间断开直接失败不重试
网络类断流is_network_error=True同样可重试清除重来与主智能体 语义与主智能体 run_streaming_attempts can_retry 判定一致
run_streaming_attempts 的放宽判定一致非网络错误4xx 等业务错误 not full_response and not tool_calls and not current_thinking
有接收时不重试
calling_tools断流时流中已推送正在调用进度事件的工具列表
重试前由 _run_loop 标记取消避免前端进度弹窗永久卡在调用中
""" """
def __init__(self, message: str, *, received_any: bool = False, is_network_error: bool = True, calling_tools: Optional[List[Dict[str, Any]]] = None): def __init__(self, message: str, *, received_any: bool = False):
super().__init__(message) super().__init__(message)
self.received_any = received_any self.received_any = received_any
self.is_network_error = is_network_error
self.calling_tools = calling_tools or []
# 模型请求失败重试策略(与主智能体 max_api_retries=4 / retry_delay_seconds=10 对齐: # 模型请求失败重试策略(与主智能体 max_api_retries=4 / retry_delay_seconds=10 对齐:
@ -336,10 +331,9 @@ class SubAgentTask:
# 安全点:写入延迟的上下文通知(不触发新一轮工作,仅让下一轮模型调用看到) # 安全点:写入延迟的上下文通知(不触发新一轮工作,仅让下一轮模型调用看到)
self._flush_pending_notifications() self._flush_pending_notifications()
# 模型请求(带失败重试,与主智能体同构:最多 5 次尝试、重试间隔 10s # 模型请求(带失败重试,与主智能体同构:最多 5 次尝试、重试间隔 10s、
# 重试范围:零接收一律可重试;有接收(输出中途断开)仅网络类断流可重试, # 仅当零接收——未收到任何文本/思考/工具调用——时重试;
# 采取「清除重来」——半截内容只在 _call_model 局部变量与已推送的 calling # 已开始收到内容后断开属于输出期间故障,直接失败不重试)
# 进度事件里,转发给主智能体的输出只发生在流完整结束后,无副作用。
assistant_message = "" assistant_message = ""
reasoning = "" reasoning = ""
tool_calls = [] tool_calls = []
@ -354,7 +348,7 @@ class SubAgentTask:
break break
except SubAgentModelCallError as exc: except SubAgentModelCallError as exc:
call_error = exc call_error = exc
can_retry = api_attempt < _SUB_AGENT_MAX_API_RETRIES and (exc.is_network_error or not exc.received_any) can_retry = api_attempt < _SUB_AGENT_MAX_API_RETRIES and not exc.received_any
ma_debug( ma_debug(
"sub_agent_api_attempt_failed", "sub_agent_api_attempt_failed",
task_id=self.task_id, task_id=self.task_id,
@ -369,17 +363,6 @@ class SubAgentTask:
) )
if not can_retry: if not can_retry:
break break
# 有接收断流重试(清除重来):本轮已推送的「正在调用」工具进度
# 条目标记取消,避免前端进度弹窗永久卡在「调用中」;
# 重试成功后模型若再次调用会推送新的条目(新一轮工具 id 不同)。
for calling_tool in exc.calling_tools:
self.emit("progress", {
"id": calling_tool.get("id"),
"tool": calling_tool.get("name", ""),
"status": "cancelled",
"args": {},
"ts": int(time.time() * 1000),
})
# 重试本身也是一次额外 API 请求 # 重试本身也是一次额外 API 请求
self.stats["api_calls"] += 1 self.stats["api_calls"] += 1
# 重试等待期间必须响应软停止/硬取消 # 重试等待期间必须响应软停止/硬取消
@ -393,8 +376,8 @@ class SubAgentTask:
continue continue
if call_error is not None: if call_error is not None:
if self.multi_agent_mode and (call_error.is_network_error or not call_error.received_any): if self.multi_agent_mode and not call_error.received_any:
# 5 次尝试全部失败(零接收或网络断流):子智能体不终止,转为 idle # 5 次尝试全部失败(均为零接收):子智能体不终止,转为 idle
# 并向 Team Leader 汇报错误,等待检查网络后重新下达指令 # 并向 Team Leader 汇报错误,等待检查网络后重新下达指令
error_report = tr( error_report = tr(
"sub_agent_task.model_call_failed_idle", "sub_agent_task.model_call_failed_idle",
@ -413,8 +396,8 @@ class SubAgentTask:
self._idle = True self._idle = True
self._persist_conversation(partial_summary=error_report[:200]) self._persist_conversation(partial_summary=error_report[:200])
continue continue
# 非网络业务错误(有接收,理论罕见)或传统后台模式(重试已耗尽): # 输出期间断开(已开始收到内容)或传统后台模式:直接失败,
# 直接失败,异常上抛由 run() 捕获后 _write_failure 落盘 failed 状态 # 异常上抛由 run() 捕获后 _write_failure 落盘 failed 状态
if self.multi_agent_mode: if self.multi_agent_mode:
# 同步把失败状态告知 Team Leader避免主智能体无感知空等 # 同步把失败状态告知 Team Leader避免主智能体无感知空等
self._forward_output_to_master( self._forward_output_to_master(
@ -796,30 +779,15 @@ class SubAgentTask:
raise asyncio.CancelledError() raise asyncio.CancelledError()
if chunk.get("error"): if chunk.get("error"):
error_info = chunk.get("error") error_info = chunk.get("error")
is_network = True
if isinstance(error_info, dict): if isinstance(error_info, dict):
error_text = ( error_text = (
error_info.get("error_message") error_info.get("error_message")
or error_info.get("error_text") or error_info.get("error_text")
or str(error_info) or str(error_info)
) )
# status_code 为空 = 网络类断流(连接错误/超时/远端断开);
# 有值4xx 等 HTTP 业务错误)不参与有接收重试放宽
is_network = error_info.get("status_code") is None
else: else:
error_text = str(error_info) error_text = str(error_info)
# 收集本轮已推送「正在调用」进度的工具,重试前需标记取消 raise SubAgentModelCallError(tr("sub_agent_task2.api_call_failed", error=error_text), received_any=received_any)
calling_tools = [
{"id": tc.get("id"), "name": tc.get("function", {}).get("name", "")}
for tc in tool_calls
if tc.get("id") and tc.get("function", {}).get("name")
]
raise SubAgentModelCallError(
tr("sub_agent_task2.api_call_failed", error=error_text),
received_any=received_any,
is_network_error=is_network,
calling_tools=calling_tools,
)
choice = (chunk.get("choices") or [{}])[0] choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {} delta = choice.get("delta") or {}
if delta.get("content"): if delta.get("content"):
@ -876,17 +844,7 @@ class SubAgentTask:
raise raise
except Exception as exc: except Exception as exc:
# chat 流本身抛出的漏网异常(如底层连接错误未被转为 error chunk 时) # chat 流本身抛出的漏网异常(如底层连接错误未被转为 error chunk 时)
calling_tools = [ raise SubAgentModelCallError(tr("sub_agent_task2.api_call_exception", error=exc), received_any=received_any) from exc
{"id": tc.get("id"), "name": tc.get("function", {}).get("name", "")}
for tc in tool_calls
if tc.get("id") and tc.get("function", {}).get("name")
]
raise SubAgentModelCallError(
tr("sub_agent_task2.api_call_exception", error=exc),
received_any=received_any,
is_network_error=True,
calling_tools=calling_tools,
) from exc
return assistant_message, reasoning, tool_calls, usage return assistant_message, reasoning, tool_calls, usage

View File

@ -319,9 +319,7 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
} }
# === API响应完成后只计算输出token === # === API响应完成后只计算输出token ===
# 断流作废轮不计 usage本论响应不完整重试成功后会按新一轮 usage 计数, if last_usage_payload:
# 若在 error 分支判断之前 apply 会导致同一回答重复计数。
if last_usage_payload and not api_error:
try: try:
web_terminal.context_manager.apply_usage_statistics(last_usage_payload) web_terminal.context_manager.apply_usage_statistics(last_usage_payload)
debug_log( debug_log(
@ -369,13 +367,12 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
error_message = f"API 请求失败HTTP {error_status}" error_message = f"API 请求失败HTTP {error_status}"
else: else:
error_message = "API 请求失败" error_message = "API 请求失败"
# 重试判定网络类断流status_code 为空:连接错误/超时/远端断开, can_retry = (
# 区别于 HTTP 业务错误)允许「有接收」重试——半截内容只存在于局部变量 api_attempt < max_api_retries
# 与前端后端历史与磁盘均未写入清除重来无副作用HTTP 业务错误 and not full_response
# 4xx 等,实际上不可能有接收)保持原逻辑仅零接收重试。 and not tool_calls
has_partial_output = bool(full_response or tool_calls or current_thinking) and not current_thinking
is_network_error = error_status is None )
can_retry = api_attempt < max_api_retries and (is_network_error or not has_partial_output)
sender('error', { sender('error', {
'message': error_message, 'message': error_message,
'status_code': error_status, 'status_code': error_status,
@ -391,17 +388,6 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
'max_attempts': max_api_retries + 1 'max_attempts': max_api_retries + 1
}) })
if can_retry: if can_retry:
if has_partial_output:
# 「清除重来」:通知前端清掉本轮 attempt 已渲染的半截内容
# (思考块含已闭合的 / 文本 / preparing 工具条目),重试的新内容
# 将由新一轮 thinking_start/text_start/tool_preparing 重新推送。
# 后端内存历史与磁盘均无半截状态,无需清理。
sender('stream_reset', {
'reason': 'stream_disconnected',
'attempt': api_attempt + 2,
'max_attempts': max_api_retries + 1,
'retry_in': retry_delay_seconds,
})
try: try:
profile = get_model_profile(getattr(web_terminal, "model_key", None)) profile = get_model_profile(getattr(web_terminal, "model_key", None))
web_terminal.apply_model_profile(profile) web_terminal.apply_model_profile(profile)

View File

@ -93,8 +93,7 @@ const appOptions = {
chatCompleteTextAction: 'completeText', chatCompleteTextAction: 'completeText',
chatAddSystemMessage: 'addSystemMessage', chatAddSystemMessage: 'addSystemMessage',
chatEnsureAssistantMessage: 'ensureAssistantMessage', chatEnsureAssistantMessage: 'ensureAssistantMessage',
chatClearStreamingResidualState: 'clearStreamingResidualState', chatClearStreamingResidualState: 'clearStreamingResidualState'
chatResetStreamingAttemptActions: 'resetStreamingAttemptActions'
}), }),
...mapActions(useInputStore, { ...mapActions(useInputStore, {
inputSetFocused: 'setInputFocused', inputSetFocused: 'setInputFocused',

View File

@ -260,27 +260,5 @@ export const aiStreamMethods = {
this.chatCompleteTextAction(full); this.chatCompleteTextAction(full);
this.$forceUpdate(); this.$forceUpdate();
this.monitorEndModelOutput(); this.monitorEndModelOutput();
},
handleStreamReset(data: any) {
// 断流重试「清除重来」:移除本轮 attempt 已渲染的半截内容
// (思考块含已闭合的 / 文本 / preparing 工具条目),重试内容将重新推送。
debugLog('[TaskPolling] 断流重试,清理本轮半截内容');
const retryAttempt = Number(data?.attempt) || 0;
const retryMax = Number(data?.max_attempts) || 0;
const retryLabel = retryAttempt && retryMax
? t('appTasks.streamRetrying', { attempt: retryAttempt, max: retryMax })
: t('appTasks.streamRetryingGeneric');
const removedActions = this.chatResetStreamingAttemptActions?.(retryLabel) || [];
for (const action of removedActions) {
if (action?.id) {
this.preparingTools?.delete(action.id);
}
if (action?.type === 'tool') {
this.toolUnregisterAction?.(action);
}
}
this.chatClearThinkingLocks?.();
this.monitorEndModelOutput();
this.$forceUpdate();
} }
}; };

View File

@ -230,12 +230,6 @@ export const lifecycleMethods = {
this.handleTextEnd(eventData, eventIdx); this.handleTextEnd(eventData, eventIdx);
break; break;
case 'stream_reset':
// 断流重试「清除重来」:清理本轮 attempt 半截内容;
// 历史重放时同样安全(后续事件会把新一轮内容重新渲染出来)
this.handleStreamReset(eventData, eventIdx);
break;
case 'tool_preparing': case 'tool_preparing':
this.handleToolPreparing(eventData, eventIdx); this.handleToolPreparing(eventData, eventIdx);
break; break;

View File

@ -1142,41 +1142,6 @@ export async function initializeLegacySocket(ctx: any) {
ctx.apiRequestPending = true; ctx.apiRequestPending = true;
}); });
// 断流重试「清除重来」:后端与 API 的流式连接在输出中途断开、即将自动重试,
// 清除本轮 attempt 已渲染的半截内容(思考块含已闭合的 / 文本 / preparing 工具条目),
// 重试的新内容会由新一轮 thinking_start/text_start/tool_preparing 重新推送。
ctx.socket.on('stream_reset', (data) => {
if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) {
return;
}
// 轮询模式下由任务事件流重放处理lifecycle.ts case 'stream_reset'
if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return;
}
socketLog('断流重试,清理本轮半截内容', data);
// 清逐字流缓冲,防止半截缓冲在重置后继续渲染
resetStreamingBuffer();
const retryAttempt = Number(data?.attempt) || 0;
const retryMax = Number(data?.max_attempts) || 0;
const retryLabel = retryAttempt && retryMax
? t('appTasks.streamRetrying', { attempt: retryAttempt, max: retryMax })
: t('appTasks.streamRetryingGeneric');
const removedActions = ctx.chatResetStreamingAttemptActions?.(retryLabel) || [];
for (const action of removedActions) {
if (action?.id) {
ctx.preparingTools?.delete(action.id);
}
if (action?.type === 'tool') {
ctx.toolUnregisterAction?.(action);
}
}
if (typeof ctx.chatClearThinkingLocks === 'function') {
ctx.chatClearThinkingLocks();
}
ctx.monitorEndModelOutput?.();
ctx.$forceUpdate();
});
// 思考流开始 // 思考流开始
ctx.socket.on('thinking_start', (data) => { ctx.socket.on('thinking_start', (data) => {
// 对话定向事件:只响应当前对话,避免运行中对话的流式输出渲染到 /new 等空白页。 // 对话定向事件:只响应当前对话,避免运行中对话的流式输出渲染到 /new 等空白页。

View File

@ -344,11 +344,9 @@ function transformMathBlocks(raw: string): string {
return `<span class="math-block" data-latex="${escapeHtmlAttribute(trimmed)}" data-display="block"></span>`; return `<span class="math-block" data-latex="${escapeHtmlAttribute(trimmed)}" data-display="block"></span>`;
}); });
// 行内 $...$Pandoc 规则:开 $ 后非空白、闭 $ 前非空白、闭 $ 后不跟数字, // 行内 $...$(排除 $ 本身和反斜杠转义)
// 避免把 "$10 ≈ ¥72 | $5" 这类相邻货币 $ 误配对成公式——配对区间若跨过
// 表格 | 分隔符,生成的占位 span 会被表格解析拦腰切断,显示为原始文本)
protectedText = protectedText.replace( protectedText = protectedText.replace(
/(?<![\\$])\$(?!\s)([^$\n]+?)(?<!\s)\$(?![\\$\d])/g, /(?<![\\$])\$([^$\n]+?)\$(?![\\$])/g,
(match, latex: string) => { (match, latex: string) => {
const trimmed = latex.replace(/\s+/g, ' ').trim(); const trimmed = latex.replace(/\s+/g, ' ').trim();
if (!trimmed) return match; if (!trimmed) return match;

View File

@ -64,8 +64,6 @@ export default {
// ── Task errors & retry (lifecycle.ts) ── // ── Task errors & retry (lifecycle.ts) ──
retrySoonTitle: 'Retrying soon', retrySoonTitle: 'Retrying soon',
retryInSeconds: 'Retrying in {n}s (attempt {attempt}/{max})\nError: {error}', retryInSeconds: 'Retrying in {n}s (attempt {attempt}/{max})\nError: {error}',
streamRetrying: 'Connection interrupted, retrying (attempt {attempt}/{max})…',
streamRetryingGeneric: 'Connection interrupted, retrying…',
toolCallFailed: 'Tool call failed', toolCallFailed: 'Tool call failed',
taskFailedTitle: 'Task failed', taskFailedTitle: 'Task failed',
apiErrorTitle: 'API call failed', apiErrorTitle: 'API call failed',

View File

@ -65,8 +65,6 @@ export default {
// ── 任务错误与重试lifecycle.ts ── // ── 任务错误与重试lifecycle.ts ──
retrySoonTitle: '即将重试', retrySoonTitle: '即将重试',
retryInSeconds: '将在 {n} 秒后重试(第 {attempt}/{max} 次)\n错误{error}', retryInSeconds: '将在 {n} 秒后重试(第 {attempt}/{max} 次)\n错误{error}',
streamRetrying: '连接中断,正在重试(第 {attempt}/{max} 次)…',
streamRetryingGeneric: '连接中断,正在重试…',
toolCallFailed: '工具调用失败', toolCallFailed: '工具调用失败',
taskFailedTitle: '任务执行失败', taskFailedTitle: '任务执行失败',
apiErrorTitle: 'API 调用失败', apiErrorTitle: 'API 调用失败',

View File

@ -419,51 +419,6 @@ export const useChatStore = defineStore('chat', {
} }
} }
}, },
// 断流重试「清除重来」:移除当前 assistant 消息中本轮 API 尝试已渲染的
// 半截内容——从 actions 末尾往前删除思考(含已闭合)/文本/preparing 工具条目,
// 遇到上一轮迭代的已完成工具结果等非本轮内容即停。复位流式标志;若消息
// 因此被清空则恢复「生成中」占位label 通常为重试提示文案)。
// 返回被移除的 action 对象列表,供调用方清理 preparingTools / 工具注册表。
resetStreamingAttemptActions(label: string = ''): any[] {
const removed: any[] = [];
let msg: any = null;
if (
this.currentMessageIndex >= 0 &&
this.messages[this.currentMessageIndex]?.role === 'assistant'
) {
msg = this.messages[this.currentMessageIndex];
} else {
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i]?.role === 'assistant') {
msg = this.messages[i];
break;
}
}
}
if (!msg || !Array.isArray(msg.actions)) return removed;
while (msg.actions.length) {
const last = msg.actions[msg.actions.length - 1];
const isThinking = last?.type === 'thinking';
const isText = last?.type === 'text';
const isPreparingTool =
last?.type === 'tool' && String(last?.tool?.status || '').toLowerCase() === 'preparing';
if (!isThinking && !isText && !isPreparingTool) break;
removed.push(last);
msg.actions.pop();
}
msg.currentStreamingType = null;
msg.activeThinkingId = null;
msg.streamingThinking = '';
msg.streamingText = '';
if (msg.actions.length === 0) {
msg.awaitingFirstContent = true;
msg.generatingLabel = label;
} else {
msg.awaitingFirstContent = false;
msg.generatingLabel = '';
}
return removed;
},
addSystemMessage(content: string, meta: any = null) { addSystemMessage(content: string, meta: any = null) {
// 与历史重建保持一致:子智能体/后台完成通知作为独立 assistant 消息渲染, // 与历史重建保持一致:子智能体/后台完成通知作为独立 assistant 消息渲染,
// 避免运行时与刷新后在垂直间距上不一致。 // 避免运行时与刷新后在垂直间距上不一致。