fix desktop empty turn handling

This commit is contained in:
dd3xp committed 2026-07-13 23:50:14 -04:00
1 parent 66d23f0894
commit c921bad773
2 files changed
+61 -13

No files matched your search

+56 -10
View File
@@ -86,12 +86,32 @@ def find_default_ga_root() -> Path:
DEFAULT_GA_ROOT = find_default_ga_root() DEFAULT_GA_ROOT = find_default_ga_root()
_FINAL_INFO_RE = re.compile(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$') _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'<summary>[\s\S]*?</summary>', 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: def strip_final_info_marker(text: Any) -> str:
return _FINAL_INFO_RE.sub('', str(text or '')) 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]]: def normalize_final_turn_segs(full: str, outputs: Any) -> Optional[List[str]]:
if not outputs or not isinstance(outputs, (list, tuple)): if not outputs or not isinstance(outputs, (list, tuple)):
return None return None
@@ -133,6 +153,7 @@ class Session:
agent: Any = None agent: Any = None
thread: Optional[threading.Thread] = None thread: Optional[threading.Thread] = None
cancel_requested: bool = False cancel_requested: bool = False
active_turn_id: str = ""
last_error: str = "" last_error: str = ""
pinned: bool = False pinned: bool = False
untitled: bool = True untitled: bool = True
@@ -749,19 +770,25 @@ class AgentManager:
if image_metas: if image_metas:
extra["images"] = image_metas extra["images"] = image_metas
user_msg = self.add_message(sess, "user", prompt, **extra) user_msg = self.add_message(sess, "user", prompt, **extra)
turn_id = uuid.uuid4().hex
sess.status = "running" sess.status = "running"
sess.cancel_requested = False sess.cancel_requested = False
sess.active_turn_id = turn_id
sess.last_error = "" sess.last_error = ""
sess.partial = {"id": sess.msg_seq + 1, "role": "assistant", "content": "", "ts": time.time(), "partial": True, 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保留双轨兜底 "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 sess.thread = t
t.start() t.start()
seq = sess.msg_seq seq = sess.msg_seq
emit_session_state(sess, "running") emit_session_state(sess, "running")
return {"ok": True, "sessionId": sid, "accepted": True, "userMessageId": user_msg["id"], "seq": seq} 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: try:
if sess.agent is None: if sess.agent is None:
sess.agent = self.make_agent(sess) sess.agent = self.make_agent(sess)
@@ -778,7 +805,10 @@ class AgentManager:
pieces = [] pieces = []
import queue as _queue import queue as _queue
while True: while True:
if sess.cancel_requested: is_current, is_cancelled = turn_state()
if not is_current:
return
if is_cancelled:
break break
try: try:
item = display_q.get(timeout=1.0) item = display_q.get(timeout=1.0)
@@ -789,7 +819,7 @@ class AgentManager:
text = str(item["next"]) text = str(item["next"])
pieces.append(text) pieces.append(text)
with self.lock: 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["content"] = "".join(pieces) if getattr(agent, "inc_out", False) else text
sess.partial["ts"] = time.time() sess.partial["ts"] = time.time()
sess.updated_at = 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()全量轮 done_outputs = normalize_final_turn_segs(full, item.get("outputs")) # done时=turn_resps.copy()全量轮
if done_outputs: if done_outputs:
with self.lock: 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["content"] = full
sess.partial["ts"] = time.time() sess.partial["ts"] = time.time()
sess.partial["updatedAt"] = sess.partial["ts"] if "updatedAt" in sess.partial else sess.partial.get("updatedAt") sess.partial["updatedAt"] = sess.partial["ts"] if "updatedAt" in sess.partial else sess.partial.get("updatedAt")
@@ -821,23 +851,34 @@ class AgentManager:
else: else:
pieces.append(str(item)) pieces.append(str(item))
if not full and pieces: 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: else:
full = "GenericAgent object has no put_task method" full = "GenericAgent object has no put_task method"
if not full: is_current, is_cancelled = turn_state()
full = "(completed)" if not is_current:
if sess.cancel_requested: return
if is_cancelled:
with self.lock: with self.lock:
sess.partial = None sess.partial = None
if sess.active_turn_id == turn_id:
sess.active_turn_id = ""
# Ensure status stays cancelled (don't overwrite) # Ensure status stays cancelled (don't overwrite)
if sess.status != "cancelled": if sess.status != "cancelled":
sess.status = "cancelled" sess.status = "cancelled"
sess.updated_at = time.time() sess.updated_at = time.time()
emit_session_state(sess, "cancelled") emit_session_state(sess, "cancelled")
return return
if not full:
raise RuntimeError("Agent turn ended without a user-visible response.")
with self.lock: with self.lock:
if sess.active_turn_id != turn_id:
return
sess.partial = None sess.partial = None
full = strip_final_info_marker(full) 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 import plan_state
plan_state.sync_plan_path_from_text(sess, full, sess.cwd or self.ga_root) plan_state.sync_plan_path_from_text(sess, full, sess.cwd or self.ga_root)
# 轨道2: 落库时带结构化全量轮(权威turn_segs),前端按轮渲染;content保留兜底 # 轨道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)) try: sess.llm_history = json.loads(json.dumps(agent.llmclient.backend.history, ensure_ascii=False, default=str))
except Exception: pass except Exception: pass
sess.status = "idle" sess.status = "idle"
sess.active_turn_id = ""
sess.last_error = "" sess.last_error = ""
emit_session_state(sess, "idle") emit_session_state(sess, "idle")
except Exception as e: except Exception as e:
tb = traceback.format_exc() tb = traceback.format_exc()
with self.lock: with self.lock:
if sess.active_turn_id != turn_id:
return
sess.partial = None sess.partial = None
sess.status = "error" sess.status = "error"
sess.active_turn_id = ""
sess.last_error = str(e) sess.last_error = str(e)
self.add_message(sess, "error", str(e)) self.add_message(sess, "error", str(e))
print(tb, file=sys.stderr) print(tb, file=sys.stderr)
@@ -905,9 +950,10 @@ class AgentManager:
partial_text = "" partial_text = ""
if sess.partial: if sess.partial:
partial_text = (sess.partial.get("content") or "").strip() 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) self.add_message(sess, "assistant", partial_text, stopped=True)
sess.status = "cancelled" sess.status = "cancelled"
sess.active_turn_id = ""
sess.partial = None sess.partial = None
sess.updated_at = time.time() sess.updated_at = time.time()
emit_session_state(sess, "cancelled") emit_session_state(sess, "cancelled")
+5 -3
View File
@@ -463,9 +463,11 @@ class GenericAgentHandler(BaseHandler):
二次确认仅在回复几乎只包含<thinking>/<summary>和一段大代码块时触发。''' 二次确认仅在回复几乎只包含<thinking>/<summary>和一段大代码块时触发。'''
content = getattr(response, 'content', '') or "" content = getattr(response, 'content', '') or ""
thinking = getattr(response, 'thinking', '') or "" thinking = getattr(response, 'thinking', '') or ""
if not response or (not content.strip() and not thinking.strip()): visible_content = re.sub(r"<thinking>[\s\S]*?</thinking>", "", content, flags=re.IGNORECASE)
yield "[Warn] LLM returned an empty response. Retrying...\n" visible_content = re.sub(r"<summary>[\s\S]*?</summary>", "", visible_content, flags=re.IGNORECASE)
return self._retry_or_exit("[System] Blank response, regenerate and tooluse") 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:]: if '[!!! 流异常中断' in content[-100:] or '!!!Error:' in content[-100:]:
return self._retry_or_exit("[System] Incomplete response. Regenerate and tooluse.") return self._retry_or_exit("[System] Incomplete response. Regenerate and tooluse.")
if 'max_tokens !!!]' in content[-100:]: if 'max_tokens !!!]' in content[-100:]: