feat: add /btw side-question subagent (#317)

Mirrors Claude Code's /btw: ask the agent a quick question without
interrupting the in-flight main task.

- frontends/btw_cmd.py: snapshot backend.history under lock + deepcopy →
  background-thread backend.raw_ask single-turn answer; never writes
  backend.history; never touches task_queue. Reuses backend.raw_ask +
  make_messages, no new LLM instance.
- frontends/chatapp_common.py: register /btw in HELP_COMMANDS; handle
  /btw in AgentChatMixin (sync via asyncio.to_thread); install() at
  module bottom so the slash hook is wired for any frontend that
  imports chatapp_common.
- frontends/stapp.py: persist display_queue + partial_response in
  st.session_state so the streaming bubble survives Streamlit reruns;
  new render_main_stream() helper covers both new tasks and resume;
  /btw branch uses plain st.rerun() (preserves dq) so the main task's
  remaining output renders in a follow-up bubble; non-/btw prompts
  explicitly call agent.abort() to keep the original "submit cancels"
  UX. Strip <summary> meta tags from text segments and drop dangling
  "LLM Running (Turn N) ..." markers in resumed partials to clean up
  the rendered chat.
This commit is contained in:
Shen Hao authored and GitHub committed 2026-05-09 15:26:05 +08:00
1 parent dd9a9c5b04
commit a2e1686218
3 files changed
+223 -32

No files matched your search

+76 -32
View File
@@ -16,6 +16,7 @@ import time, json, re, threading, queue
from agentmain import GeneraticAgent
import chatapp_common # activate /continue command (monkey patches GeneraticAgent)
from continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages
from btw_cmd import handle_frontend_command as btw_handle_frontend
st.set_page_config(page_title="Cowork", layout="wide")
@@ -139,6 +140,8 @@ def fold_turns(text):
segments.append({'type': 'fold', 'title': title, 'content': content})
else: segments.append({'type': 'text', 'content': marker + content})
return segments
_SUMMARY_TAG_RE = re.compile(r'<summary>.*?</summary>\s*', re.DOTALL)
def render_segments(segments, suffix=''):
# 整块重画:调用方用 slot.container() 包裹,保证 DOM 路径稳定、跨 rerun 对齐(消除"灰色重影")。
# heartbeat 空转时 segments 不变 → Streamlit 后端 diff 无变化 → 前端零闪烁;
@@ -147,22 +150,63 @@ def render_segments(segments, suffix=''):
if seg['type'] == 'fold':
with st.expander(seg['title'], expanded=False): st.markdown(seg['content'])
else:
st.markdown(seg['content'] + suffix)
# Strip <summary> meta tags from text segments — folded turns already
# promote them to expander titles; for the first/last segments
# they'd otherwise leak into the chat as raw text (esp. after /continue
# restores a multi-turn body).
st.markdown(_SUMMARY_TAG_RE.sub('', seg['content']) + suffix)
def agent_backend_stream(prompt):
display_queue = agent.put_task(prompt, source="user")
response = ''
try:
while True:
try: item = display_queue.get(timeout=1)
except queue.Empty:
yield response # heartbeat: let outer st.markdown() run → Streamlit checks StopException
continue
if 'next' in item:
response = item['next']; yield response
if 'done' in item:
yield item['done']; break
finally: agent.abort()
def agent_backend_stream(prompt=None):
"""Drain main task display_queue.
- prompt given: start a fresh task; new dq is kept in session_state.
- prompt is None: resume a dq left in session_state by a prior run (e.g. after /btw).
Per-chunk progress is mirrored to session_state.partial_response so the rendered
bubble survives reruns. No implicit agent.abort() — explicit stop is on the Stop button."""
if prompt is not None:
st.session_state.display_queue = agent.put_task(prompt, source="user")
st.session_state.partial_response = ''
dq = st.session_state.get('display_queue')
if dq is None: return
# Drop a dangling 'LLM Running (Turn N) ...' marker if the captured partial
# ended right at a turn boundary with no content yet — otherwise the resume
# bubble flashes as a marker-only gray line. The marker reappears with
# content on the next chunk (raw_resp is cumulative).
response = re.sub(r'\**LLM Running \(Turn \d+\) \.\.\.\**\s*$',
'', st.session_state.get('partial_response', '')).rstrip()
while True:
try: item = dq.get(timeout=1)
except queue.Empty:
yield response # heartbeat: let outer st.markdown() run → Streamlit checks StopException
continue
if 'next' in item:
response = item['next']
st.session_state.partial_response = response
yield response
if 'done' in item:
st.session_state.display_queue = None
st.session_state.partial_response = ''
yield item['done']; break
def render_main_stream(prompt=None):
"""Render the assistant bubble for the main task (new or resumed). Saves final to messages."""
with st.chat_message("assistant"):
frozen = 0; live = st.empty(); response = ''
CURSOR = ' ▌'
for response in agent_backend_stream(prompt):
segs = fold_turns(response)
n_done = max(0, len(segs) - 1)
while frozen < n_done:
with live.container(): render_segments([segs[frozen]])
live = st.empty(); frozen += 1
with live.container(): render_segments([segs[-1]], suffix=CURSOR) # live 区域
segs = fold_turns(response)
for i in range(frozen, len(segs)):
with live.container(): render_segments([segs[i]])
if i < len(segs) - 1: live = st.empty()
if response:
st.session_state.messages.append({"role": "assistant", "content": response})
st.session_state.last_reply_time = int(time.time())
if "messages" not in st.session_state: st.session_state.messages = []
for msg in st.session_state.messages:
@@ -234,26 +278,26 @@ if prompt := st.chat_input("any task?"):
st.session_state.messages = list(st.session_state.messages) + \
[{"role": "user", "content": cmd, "time": ts}] + tail
_reset_and_rerun()
if cmd.startswith("/btw"):
answer = btw_handle_frontend(agent, cmd) # sync; bypasses put_task → main agent.run() untouched
st.session_state.messages = list(st.session_state.messages) + [
{"role": "user", "content": prompt, "time": ts},
{"role": "assistant", "content": answer, "time": ts},
]
st.rerun() # preserve display_queue/partial_response so resume path drains the running main task
# Regular prompt: cancel any in-flight task to match original "submit cancels" UX.
# (/btw branch above is the only path that intentionally lets the prior task keep streaming.)
if st.session_state.get('display_queue') is not None:
agent.abort()
st.session_state.display_queue = None
st.session_state.partial_response = ''
st.session_state.messages.append({"role": "user", "content": prompt})
if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk')
with st.chat_message("user"): st.markdown(prompt)
with st.chat_message("assistant"):
frozen = 0; live = st.empty(); response = ''
CURSOR = ' ▌'
for response in agent_backend_stream(prompt):
segs = fold_turns(response)
n_done = max(0, len(segs) - 1)
while frozen < n_done:
with live.container(): render_segments([segs[frozen]])
live = st.empty(); frozen += 1
with live.container(): render_segments([segs[-1]], suffix=CURSOR) # live 区域
segs = fold_turns(response)
for i in range(frozen, len(segs)):
with live.container(): render_segments([segs[i]])
if i < len(segs) - 1: live = st.empty()
st.session_state.messages.append({"role": "assistant", "content": response})
st.session_state.last_reply_time = int(time.time())
render_main_stream(prompt)
elif st.session_state.get('display_queue') is not None:
# No new prompt but a task is mid-flight (typically a /btw rerun) — resume drain.
render_main_stream()
if st.session_state.autonomous_enabled:
st.markdown(f"""<div id="last-reply-time" style="display:none">{st.session_state.get('last_reply_time', int(time.time()))}</div>""", unsafe_allow_html=True)