From c921bad7738e428f25d37cf2d383576d17bf2731 Mon Sep 17 00:00:00 2001 From: dd3xp Date: Mon, 13 Jul 2026 23:50:14 -0400 Subject: [PATCH] fix desktop empty turn handling --- frontends/desktop_bridge.py | 66 +++++++++++++++++++++++++++++++------ ga.py | 8 +++-- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py index c6aae3a6..18d41ff4 100644 --- a/frontends/desktop_bridge.py +++ b/frontends/desktop_bridge.py @@ -86,12 +86,32 @@ def find_default_ga_root() -> Path: DEFAULT_GA_ROOT = find_default_ga_root() _FINAL_INFO_RE = re.compile(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$') +_RUNNING_MARKER_RE = re.compile( + r'^\s*\*{0,2}(?:LLM Running\s*\(Turn\s*\d+\)|Turn\s*\d+)\s*\.\.\.\*{0,2}\s*$', + re.IGNORECASE | re.MULTILINE, +) +_SUMMARY_RE = re.compile(r'[\s\S]*?', re.IGNORECASE) +_TOOL_TRANSCRIPT_RE = re.compile( + r'(?ms)^\s*.*?Tool:\s*`.*?`{4}[^\n]*\n.*?`{4}\s*(?:`{5}.*?`{5}\s*)?' +) def strip_final_info_marker(text: Any) -> str: return _FINAL_INFO_RE.sub('', str(text or '')) +def strip_non_user_visible_text(text: Any) -> str: + cleaned = strip_final_info_marker(text) + cleaned = _RUNNING_MARKER_RE.sub('', cleaned) + cleaned = _SUMMARY_RE.sub('', cleaned) + cleaned = _TOOL_TRANSCRIPT_RE.sub('', cleaned) + return cleaned.strip() + + +def has_user_visible_text(text: Any) -> bool: + return bool(strip_non_user_visible_text(text)) + + def normalize_final_turn_segs(full: str, outputs: Any) -> Optional[List[str]]: if not outputs or not isinstance(outputs, (list, tuple)): return None @@ -133,6 +153,7 @@ class Session: agent: Any = None thread: Optional[threading.Thread] = None cancel_requested: bool = False + active_turn_id: str = "" last_error: str = "" pinned: bool = False untitled: bool = True @@ -749,19 +770,25 @@ class AgentManager: if image_metas: extra["images"] = image_metas user_msg = self.add_message(sess, "user", prompt, **extra) + turn_id = uuid.uuid4().hex sess.status = "running" sess.cancel_requested = False + sess.active_turn_id = turn_id sess.last_error = "" sess.partial = {"id": sess.msg_seq + 1, "role": "assistant", "content": "", "ts": time.time(), "partial": True, "curr_turn": 0, "turn_segs": []} # turn_segs[i]=第i轮全文(权威结构化,前端按轮渲染);content保留双轨兜底 - t = threading.Thread(target=self.run_agent_turn, args=(sess, prompt, None), daemon=True, name=f"Turn-{sid}") + t = threading.Thread(target=self.run_agent_turn, args=(sess, prompt, None, turn_id), daemon=True, name=f"Turn-{sid}") sess.thread = t t.start() seq = sess.msg_seq emit_session_state(sess, "running") return {"ok": True, "sessionId": sid, "accepted": True, "userMessageId": user_msg["id"], "seq": seq} - def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = None): + def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = None, turn_id: str = ""): + def turn_state() -> tuple[bool, bool]: + with self.lock: + return sess.active_turn_id == turn_id, sess.cancel_requested + try: if sess.agent is None: sess.agent = self.make_agent(sess) @@ -778,7 +805,10 @@ class AgentManager: pieces = [] import queue as _queue while True: - if sess.cancel_requested: + is_current, is_cancelled = turn_state() + if not is_current: + return + if is_cancelled: break try: item = display_q.get(timeout=1.0) @@ -789,7 +819,7 @@ class AgentManager: text = str(item["next"]) pieces.append(text) with self.lock: - if sess.partial is not None: + if sess.partial is not None and sess.active_turn_id == turn_id: sess.partial["content"] = "".join(pieces) if getattr(agent, "inc_out", False) else text sess.partial["ts"] = time.time() sess.updated_at = time.time() @@ -810,7 +840,7 @@ class AgentManager: done_outputs = normalize_final_turn_segs(full, item.get("outputs")) # done时=turn_resps.copy()全量轮 if done_outputs: with self.lock: - if sess.partial is not None: + if sess.partial is not None and sess.active_turn_id == turn_id: sess.partial["content"] = full sess.partial["ts"] = time.time() sess.partial["updatedAt"] = sess.partial["ts"] if "updatedAt" in sess.partial else sess.partial.get("updatedAt") @@ -821,23 +851,34 @@ class AgentManager: else: pieces.append(str(item)) if not full and pieces: - full = pieces[-1] if not getattr(agent, "inc_out", False) else "".join(pieces) + candidate = pieces[-1] if not getattr(agent, "inc_out", False) else "".join(pieces) + if has_user_visible_text(candidate): + full = candidate else: full = "GenericAgent object has no put_task method" - if not full: - full = "(completed)" - if sess.cancel_requested: + is_current, is_cancelled = turn_state() + if not is_current: + return + if is_cancelled: with self.lock: sess.partial = None + if sess.active_turn_id == turn_id: + sess.active_turn_id = "" # Ensure status stays cancelled (don't overwrite) if sess.status != "cancelled": sess.status = "cancelled" sess.updated_at = time.time() emit_session_state(sess, "cancelled") return + if not full: + raise RuntimeError("Agent turn ended without a user-visible response.") with self.lock: + if sess.active_turn_id != turn_id: + return sess.partial = None full = strip_final_info_marker(full) + if not has_user_visible_text(full): + raise RuntimeError("Agent turn ended without a user-visible response.") import plan_state plan_state.sync_plan_path_from_text(sess, full, sess.cwd or self.ga_root) # 轨道2: 落库时带结构化全量轮(权威turn_segs),前端按轮渲染;content保留兜底 @@ -849,13 +890,17 @@ class AgentManager: try: sess.llm_history = json.loads(json.dumps(agent.llmclient.backend.history, ensure_ascii=False, default=str)) except Exception: pass sess.status = "idle" + sess.active_turn_id = "" sess.last_error = "" emit_session_state(sess, "idle") except Exception as e: tb = traceback.format_exc() with self.lock: + if sess.active_turn_id != turn_id: + return sess.partial = None sess.status = "error" + sess.active_turn_id = "" sess.last_error = str(e) self.add_message(sess, "error", str(e)) print(tb, file=sys.stderr) @@ -905,9 +950,10 @@ class AgentManager: partial_text = "" if sess.partial: partial_text = (sess.partial.get("content") or "").strip() - if partial_text: + if partial_text and has_user_visible_text(partial_text): self.add_message(sess, "assistant", partial_text, stopped=True) sess.status = "cancelled" + sess.active_turn_id = "" sess.partial = None sess.updated_at = time.time() emit_session_state(sess, "cancelled") diff --git a/ga.py b/ga.py index a652868b..a15f1fd9 100644 --- a/ga.py +++ b/ga.py @@ -463,9 +463,11 @@ class GenericAgentHandler(BaseHandler): 二次确认仅在回复几乎只包含/和一段大代码块时触发。''' content = getattr(response, 'content', '') or "" thinking = getattr(response, 'thinking', '') or "" - if not response or (not content.strip() and not thinking.strip()): - yield "[Warn] LLM returned an empty response. Retrying...\n" - return self._retry_or_exit("[System] Blank response, regenerate and tooluse") + visible_content = re.sub(r"[\s\S]*?", "", content, flags=re.IGNORECASE) + visible_content = re.sub(r"[\s\S]*?", "", visible_content, flags=re.IGNORECASE) + if not response or not visible_content.strip(): + yield "[Warn] LLM returned no user-visible response. Retrying...\n" + return self._retry_or_exit("[System] Blank response. Regenerate with a user-visible answer or call a tool.") if '[!!! 流异常中断' in content[-100:] or '!!!Error:' in content[-100:]: return self._retry_or_exit("[System] Incomplete response. Regenerate and tooluse.") if 'max_tokens !!!]' in content[-100:]: