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:]: