mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 21:57:14 +08:00
fix(agent): don't drop tool_calls from empty-response retry
This commit is contained in:
@@ -254,8 +254,15 @@ class AgentStreamExecutor:
|
|||||||
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=False)
|
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=False)
|
||||||
final_response = assistant_msg
|
final_response = assistant_msg
|
||||||
|
|
||||||
# 如果还是空,才使用 fallback
|
# If LLM responded with tool_calls instead of text, fall through
|
||||||
if not assistant_msg and not tool_calls:
|
# to the tool execution path below (don't break the loop).
|
||||||
|
if tool_calls:
|
||||||
|
logger.info(
|
||||||
|
f"[Agent] LLM returned tool_calls in explicit-response retry, "
|
||||||
|
f"continuing to execute tools instead of breaking"
|
||||||
|
)
|
||||||
|
elif not assistant_msg:
|
||||||
|
# Still empty (no text and no tool_calls): use fallback
|
||||||
logger.warning(f"[Agent] Still empty after explicit request")
|
logger.warning(f"[Agent] Still empty after explicit request")
|
||||||
final_response = (
|
final_response = (
|
||||||
"抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。"
|
"抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。"
|
||||||
@@ -270,12 +277,15 @@ class AgentStreamExecutor:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"💭 {assistant_msg[:150]}{'...' if len(assistant_msg) > 150 else ''}")
|
logger.info(f"💭 {assistant_msg[:150]}{'...' if len(assistant_msg) > 150 else ''}")
|
||||||
|
|
||||||
logger.debug(f"✅ 完成 (无工具调用)")
|
# If the explicit-response retry produced tool_calls, skip the break
|
||||||
self._emit_event("turn_end", {
|
# and continue down to the tool execution branch in this same iteration.
|
||||||
"turn": turn,
|
if not tool_calls:
|
||||||
"has_tool_calls": False
|
logger.debug(f"✅ 完成 (无工具调用)")
|
||||||
})
|
self._emit_event("turn_end", {
|
||||||
break
|
"turn": turn,
|
||||||
|
"has_tool_calls": False
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
# Log tool calls with arguments
|
# Log tool calls with arguments
|
||||||
tool_calls_str = []
|
tool_calls_str = []
|
||||||
|
|||||||
@@ -269,6 +269,25 @@ class WebChannel(ChatChannel):
|
|||||||
if tool_calls:
|
if tool_calls:
|
||||||
q.put({"type": "message_end", "has_tool_calls": True})
|
q.put({"type": "message_end", "has_tool_calls": True})
|
||||||
|
|
||||||
|
elif event_type == "agent_end":
|
||||||
|
# Safety net: if the agent finishes with an empty final_response,
|
||||||
|
# chat_channel skips _send_reply (because reply.content is empty),
|
||||||
|
# which means no "done" event is ever emitted and the SSE stream
|
||||||
|
# would hang until the 10-min idle timeout. Push a fallback "done"
|
||||||
|
# here so the frontend always gets closure.
|
||||||
|
final_response = data.get("final_response", "")
|
||||||
|
if not final_response or not str(final_response).strip():
|
||||||
|
logger.warning(
|
||||||
|
f"[WebChannel] agent_end with empty final_response for "
|
||||||
|
f"request {request_id}, sending fallback done"
|
||||||
|
)
|
||||||
|
q.put({
|
||||||
|
"type": "done",
|
||||||
|
"content": "(模型未返回任何内容,请重试或换一种方式描述你的需求)",
|
||||||
|
"request_id": request_id,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
})
|
||||||
|
|
||||||
elif event_type == "file_to_send":
|
elif event_type == "file_to_send":
|
||||||
file_path = data.get("path", "")
|
file_path = data.get("path", "")
|
||||||
file_name = data.get("file_name", os.path.basename(file_path))
|
file_name = data.get("file_name", os.path.basename(file_path))
|
||||||
|
|||||||
@@ -653,6 +653,8 @@ class GoogleGeminiBot(Bot):
|
|||||||
chunk_count = 0
|
chunk_count = 0
|
||||||
last_finish_reason = None
|
last_finish_reason = None
|
||||||
last_safety_ratings = None
|
last_safety_ratings = None
|
||||||
|
raw_chunks = [] # Buffer raw chunks for diagnostics on empty response
|
||||||
|
non_text_part_keys = [] # Track non-text/functionCall part keys (e.g. thoughtSignature)
|
||||||
|
|
||||||
for line in response.iter_lines():
|
for line in response.iter_lines():
|
||||||
if not line:
|
if not line:
|
||||||
@@ -670,10 +672,16 @@ class GoogleGeminiBot(Bot):
|
|||||||
try:
|
try:
|
||||||
chunk_data = json.loads(line)
|
chunk_data = json.loads(line)
|
||||||
chunk_count += 1
|
chunk_count += 1
|
||||||
|
raw_chunks.append(chunk_data)
|
||||||
|
|
||||||
candidates = chunk_data.get("candidates", [])
|
candidates = chunk_data.get("candidates", [])
|
||||||
if not candidates:
|
if not candidates:
|
||||||
logger.debug("[Gemini] No candidates in chunk")
|
# Could be a chunk with only usageMetadata / promptFeedback
|
||||||
|
prompt_feedback = chunk_data.get("promptFeedback")
|
||||||
|
if prompt_feedback:
|
||||||
|
logger.warning(f"[Gemini] promptFeedback in chunk: {prompt_feedback}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[Gemini] No candidates in chunk: {chunk_data}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
candidate = candidates[0]
|
candidate = candidates[0]
|
||||||
@@ -688,10 +696,16 @@ class GoogleGeminiBot(Bot):
|
|||||||
parts = content.get("parts", [])
|
parts = content.get("parts", [])
|
||||||
|
|
||||||
if not parts:
|
if not parts:
|
||||||
logger.debug("[Gemini] No parts in candidate content")
|
logger.debug(f"[Gemini] No parts in candidate content, candidate={candidate}")
|
||||||
|
|
||||||
# Stream text content
|
# Stream text content
|
||||||
for part in parts:
|
for part in parts:
|
||||||
|
# Track unknown part types for diagnostics
|
||||||
|
if "text" not in part and "functionCall" not in part:
|
||||||
|
for k in part.keys():
|
||||||
|
if k not in non_text_part_keys:
|
||||||
|
non_text_part_keys.append(k)
|
||||||
|
|
||||||
if "text" in part and part["text"]:
|
if "text" in part and part["text"]:
|
||||||
has_content = True
|
has_content = True
|
||||||
yield {
|
yield {
|
||||||
@@ -721,7 +735,7 @@ class GoogleGeminiBot(Bot):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except json.JSONDecodeError as je:
|
except json.JSONDecodeError as je:
|
||||||
logger.debug(f"[Gemini] JSON decode error: {je}")
|
logger.debug(f"[Gemini] JSON decode error: {je}, line={line[:500]}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Send tool calls if any were collected
|
# Send tool calls if any were collected
|
||||||
@@ -739,9 +753,24 @@ class GoogleGeminiBot(Bot):
|
|||||||
}
|
}
|
||||||
has_sent_tool_calls = True
|
has_sent_tool_calls = True
|
||||||
|
|
||||||
# 如果返回空响应,记录详细警告
|
# 如果返回空响应,dump 完整原始 chunks 以便诊断
|
||||||
if not has_content and not all_tool_calls:
|
if not has_content and not all_tool_calls:
|
||||||
logger.warning(f"[Gemini] ⚠️ Empty response detected!")
|
logger.warning(
|
||||||
|
f"[Gemini] ⚠️ Empty response detected! "
|
||||||
|
f"chunks={chunk_count}, finish_reason={last_finish_reason}, "
|
||||||
|
f"non_text_part_keys={non_text_part_keys}"
|
||||||
|
)
|
||||||
|
if last_safety_ratings:
|
||||||
|
logger.warning(f"[Gemini] safetyRatings: {last_safety_ratings}")
|
||||||
|
# Dump raw chunks (truncate each to avoid huge logs)
|
||||||
|
try:
|
||||||
|
for i, ch in enumerate(raw_chunks):
|
||||||
|
ch_str = json.dumps(ch, ensure_ascii=False)
|
||||||
|
if len(ch_str) > 2000:
|
||||||
|
ch_str = ch_str[:2000] + f"...[truncated, total {len(ch_str)} chars]"
|
||||||
|
logger.warning(f"[Gemini] raw chunk[{i}]: {ch_str}")
|
||||||
|
except Exception as dump_err:
|
||||||
|
logger.warning(f"[Gemini] Failed to dump raw chunks: {dump_err}")
|
||||||
|
|
||||||
# Final chunk
|
# Final chunk
|
||||||
yield {
|
yield {
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ class CowCliPlugin(Plugin):
|
|||||||
return f"⚙️ {key}: {val}"
|
return f"⚙️ {key}: {val}"
|
||||||
|
|
||||||
def _config_set(self, key: str, value_str: str) -> str:
|
def _config_set(self, key: str, value_str: str) -> str:
|
||||||
from config import conf, load_config
|
from config import conf, load_config, available_setting
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
if key not in self._CONFIG_WRITABLE:
|
if key not in self._CONFIG_WRITABLE:
|
||||||
@@ -402,10 +402,19 @@ class CowCliPlugin(Plugin):
|
|||||||
|
|
||||||
# Sync updated values to environment variables so that load_config()
|
# Sync updated values to environment variables so that load_config()
|
||||||
# won't overwrite the new value with a stale env var (common in Docker).
|
# won't overwrite the new value with a stale env var (common in Docker).
|
||||||
from config import available_setting
|
# Match env var keys case-insensitively (Docker compose typically uses
|
||||||
|
# upper-case like MODEL, but lower-case is also possible).
|
||||||
|
synced_envs = {}
|
||||||
for k, v in updates.items():
|
for k, v in updates.items():
|
||||||
if k in available_setting and k.upper() in os.environ:
|
if k not in available_setting:
|
||||||
os.environ[k.upper()] = str(v)
|
continue
|
||||||
|
str_val = str(v)
|
||||||
|
k_lower = k.lower()
|
||||||
|
for env_key in list(os.environ):
|
||||||
|
if env_key.lower() == k_lower:
|
||||||
|
os.environ[env_key] = str_val
|
||||||
|
synced_envs[env_key] = str_val
|
||||||
|
logger.info(f"[CowCli] config update: {updates}, synced envs: {synced_envs}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
load_config()
|
load_config()
|
||||||
|
|||||||
Reference in New Issue
Block a user