feat(agent): support user-initiated cancel for in-flight agent runs

This commit is contained in:
zhayujie
2026-05-26 23:36:09 +08:00
parent ad2db1a776
commit 8d67177a1b
19 changed files with 691 additions and 22 deletions

View File

@@ -438,8 +438,21 @@ class ChatChannel(Channel):
return func
# Chat commands that must bypass the per-session serial queue,
# otherwise /cancel would queue behind the task it tries to cancel.
# Use /cancel (not /stop) to avoid colliding with `cow stop` CLI.
_BYPASS_QUEUE_COMMANDS = ("/cancel",)
def produce(self, context: Context):
session_id = context["session_id"]
# Fast path: /cancel must not enter the queue.
if context.type == ContextType.TEXT and context.content:
stripped = context.content.strip().lower()
if stripped in self._BYPASS_QUEUE_COMMANDS:
self._handle_cancel_command(context, session_id)
return
with self.lock:
if session_id not in self.sessions:
self.sessions[session_id] = [
@@ -451,6 +464,29 @@ class ChatChannel(Channel):
else:
self.sessions[session_id][0].put(context)
def _handle_cancel_command(self, context: Context, session_id: str) -> None:
"""Cancel any in-flight agent run for *session_id* and reply inline.
Runs synchronously on the caller's thread. Reply is sent through
_send_reply so plugins (e.g. logging) still observe it.
"""
try:
from agent.protocol import get_cancel_registry
from bridge.reply import Reply, ReplyType
cancelled = get_cancel_registry().cancel_session(session_id)
text = (
"🛑 已中止"
if cancelled > 0
else "当前没有可中止的任务。"
)
logger.info(
f"[chat_channel] /cancel fast-path: session={session_id}, cancelled={cancelled}"
)
self._send_reply(context, Reply(ReplyType.TEXT, text))
except Exception as e:
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
def consume(self):
while True:

View File

@@ -752,6 +752,9 @@ class FeiShuChanel(ChatChannel):
init_in_flight = [False]
# 一旦初始化失败就长期标记为 disabled本次回复不再尝试任何流式调用
disabled = [False]
# True after agent_cancelled: agent_end stops rewriting the card
# with stale final_response and just finalizes current content.
cancelled = [False]
lock = threading.Lock()
# ---- 异步推送队列 ----------------------------------------------------
@@ -1076,18 +1079,42 @@ class FeiShuChanel(ChatChannel):
message_id[0] = None
sequence[0] = 0
elif event_type == "agent_cancelled":
# Lock channel into "no-rewrite" mode: the subsequent
# agent_end's final_response is from the last *completed*
# turn (the user already saw it), so rewriting the card
# would duplicate it visually.
with lock:
cancelled[0] = True
elif event_type == "agent_end":
# 最终回复:用 final_response 覆盖当前流式卡片,然后关闭流式模式。
final_response = data.get("final_response", "")
if not final_response:
return
final_text = str(final_response)
# 标记 streamed 让 chat_channel 跳过 send()
context["feishu_streamed"] = True
with lock:
was_cancelled = cancelled[0]
has_card = card_id[0] is not None
init_busy = init_in_flight[0]
pending_text = current_text[0]
if was_cancelled:
# Cancelled path: finalize the in-flight card with
# partial output (or a short marker if empty); drop
# stale final_response to avoid duplicating last turn.
if has_card:
_drain_push_queue()
partial = (pending_text or "").rstrip()
final_text = partial or "_(已中止)_"
_stream_update_text(final_text)
_close_streaming_mode(final_text)
push_queue.put(None)
return
if not final_response:
return
final_text = str(final_response)
# 罕见情况agent_end 触发时还没创建过卡片(极快返回 / 没有
# message_update主动创建一张承载 final_text。

View File

@@ -445,7 +445,7 @@
bg-primary-400 text-white hover:bg-primary-500
disabled:bg-slate-300 dark:disabled:bg-slate-600
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
disabled onclick="sendMessage()">
disabled>
<i class="fas fa-paper-plane text-sm"></i>
</button>
</div>

View File

@@ -1367,3 +1367,35 @@
text-align: right;
}
.voice-pill audio { display: none; }
/* Send button toggles into a Stop button while an SSE stream is in flight.
Match the look of the disabled send button (light grey block + white
glyph) so it reads as the same visual element, just paused/idle from
sending perspective and clickable to stop. */
#send-btn.send-btn-cancel {
background-color: rgb(203 213 225) !important; /* slate-300, == disabled send-btn */
color: white !important;
}
#send-btn.send-btn-cancel:hover {
background-color: rgb(148 163 184) !important; /* slate-400 */
}
#send-btn.send-btn-cancel:disabled {
background-color: rgb(226 232 240) !important; /* slate-200, while stop is in flight */
color: white !important;
cursor: progress;
}
.dark #send-btn.send-btn-cancel {
background-color: rgb(71 85 105) !important; /* slate-600, == dark disabled send-btn */
color: white !important;
}
.dark #send-btn.send-btn-cancel:hover {
background-color: rgb(100 116 139) !important; /* slate-500 */
}
.dark #send-btn.send-btn-cancel:disabled {
background-color: rgb(51 65 85) !important; /* slate-700 */
color: rgb(203 213 225) !important;
}
.agent-cancelled-tag {
font-style: italic;
}

View File

@@ -1016,7 +1016,60 @@ const inputHistory = [];
let historyIdx = -1;
let historySavedDraft = '';
// While an SSE stream is in flight, the send button morphs into a cancel
// button. Only one in-flight request is supported at a time.
let activeRequestId = null;
let sendBtnMode = 'send'; // 'send' | 'cancel'
function setSendBtnCancelMode(requestId) {
activeRequestId = requestId;
sendBtnMode = 'cancel';
sendBtn.disabled = false;
sendBtn.classList.add('send-btn-cancel');
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
}
function resetSendBtnSendMode() {
activeRequestId = null;
sendBtnMode = 'send';
sendBtn.classList.remove('send-btn-cancel');
sendBtn.title = '';
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
updateSendBtnState();
}
function requestCancel() {
const reqId = activeRequestId;
if (!reqId) return;
fetch('/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: reqId, session_id: sessionId, lang: currentLang }),
}).catch(err => {
console.warn('[cancel] request failed', err);
});
// Optimistic UI lock so the click visibly registers before the SSE
// "cancelled" event arrives.
sendBtn.disabled = true;
sendBtn.title = (currentLang === 'zh' ? '已中止' : 'Cancelled');
}
// Button click is the only path to Cancel. Pressing Enter still calls
// sendMessage() so users can submit "/cancel" as a regular slash command.
sendBtn.addEventListener('click', () => {
if (sendBtnMode === 'cancel') {
requestCancel();
} else {
sendMessage();
}
});
function updateSendBtnState() {
if (sendBtnMode === 'cancel') {
// Don't downgrade a Cancel button on input edits.
return;
}
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
}
@@ -1264,6 +1317,7 @@ const SLASH_COMMANDS = [
{ cmd: '/knowledge on', desc: '开启知识库' },
{ cmd: '/knowledge off', desc: '关闭知识库' },
{ cmd: '/config', desc: '查看当前配置' },
{ cmd: '/cancel', desc: '中止当前正在运行的 Agent 任务' },
{ cmd: '/logs', desc: '查看最近日志' },
{ cmd: '/version', desc: '查看版本' },
];
@@ -1534,6 +1588,7 @@ function sendVoiceMessage(text, audioUrl) {
stream: true,
timestamp: timestamp.toISOString(),
is_voice: true,
lang: currentLang,
};
const MAX_RETRIES = 2;
@@ -1547,7 +1602,12 @@ function sendVoiceMessage(text, audioUrl) {
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
if (data.stream) {
if (data.inline_reply) {
// Synchronous fast-path reply (e.g. /cancel); skip SSE.
loadingEl.remove();
addBotMessage(data.inline_reply, new Date());
} else if (data.stream) {
setSendBtnCancelMode(data.request_id);
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
} else {
loadingContainers[data.request_id] = loadingEl;
@@ -1555,6 +1615,7 @@ function sendVoiceMessage(text, audioUrl) {
} else {
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
})
.catch(err => {
@@ -1591,6 +1652,10 @@ function addUserVoiceMessage(audioUrl, caption, timestamp) {
}
function sendMessage() {
// Do NOT branch on sendBtnMode here: Enter should always send (so
// typing "/cancel" submits normally). Cancel is wired only to the
// send button's pointer click — see send-btn listener above.
const text = chatInput.value.trim();
if (!text && pendingAttachments.length === 0) return;
@@ -1619,7 +1684,7 @@ function sendMessage() {
renderAttachmentPreview();
sendBtn.disabled = true;
const body = { session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() };
const body = { session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString(), lang: currentLang };
if (attachments.length > 0) {
body.attachments = attachments.map(a => ({
file_path: a.file_path,
@@ -1641,7 +1706,13 @@ function sendMessage() {
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
if (data.stream) {
if (data.inline_reply) {
// Channel handled synchronously (e.g. /cancel fast-path);
// render as a bot bubble and skip SSE entirely.
loadingEl.remove();
addBotMessage(data.inline_reply, new Date());
} else if (data.stream) {
setSendBtnCancelMode(data.request_id);
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
} else {
loadingContainers[data.request_id] = loadingEl;
@@ -1649,12 +1720,14 @@ function sendMessage() {
} else {
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
})
.catch(err => {
if (err.name === 'AbortError') {
loadingEl.remove();
addBotMessage(t('error_timeout'), new Date());
resetSendBtnSendMode();
return;
}
if (attempt < MAX_RETRIES) {
@@ -1664,6 +1737,7 @@ function sendMessage() {
}
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
});
}
@@ -1919,14 +1993,33 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
stepsEl.appendChild(wrap);
scrollChatToBottom();
} else if (item.type === 'cancelled') {
// Agent acknowledged the stop; mark the bubble. A trailing
// "done" still arrives with the partial answer.
ensureBotEl();
if (currentReasoningEl) {
finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText);
currentReasoningEl = null;
reasoningText = '';
}
if (!botEl.querySelector('.agent-cancelled-tag')) {
const tag = document.createElement('div');
tag.className = 'agent-cancelled-tag text-xs text-amber-600 dark:text-amber-400 mt-1';
tag.textContent = (currentLang === 'zh') ? '已中止' : 'Cancelled';
stepsEl.appendChild(tag);
}
resetSendBtnSendMode();
} else if (item.type === 'done') {
// Don't close the stream yet: the backend keeps it open
// for a short tail to deliver async attachments such as
// TTS audio (`voice_attach`). It will close the stream on
// its own via onerror once the tail expires.
done = true;
resetSendBtnSendMode();
const finalText = item.content || accumulatedText;
const finalTextRaw = item.content || accumulatedText;
const finalText = localizeCancelMarker(finalTextRaw);
if (!botEl && finalText) {
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
@@ -1934,7 +2027,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
} else if (botEl) {
contentEl.classList.remove('sse-streaming');
if (finalText) contentEl.innerHTML = renderMarkdown(finalText);
contentEl.dataset.rawMd = finalText || '';
contentEl.dataset.rawMd = finalTextRaw || '';
const copyBtn = botEl.querySelector('.copy-msg-btn');
if (copyBtn && finalText) copyBtn.style.display = '';
applyHighlighting(botEl);
@@ -1964,6 +2057,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
delete activeStreams[requestId];
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
};
@@ -2000,6 +2094,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
applyHighlighting(botEl);
bindChatKnowledgeLinks(botEl);
}
resetSendBtnSendMode();
};
}
@@ -2238,13 +2333,23 @@ function _renderSentFileFromToolResult(step) {
`<i class="fas fa-file-download" style="color:#6b7280;"></i> ${escapeHtml(fileName)}</a></div>`;
}
// Cosmetic translator for cancel markers persisted in history.
// History keeps the English canonical form for the LLM; only display is localized.
function localizeCancelMarker(text) {
if (!text) return text;
if (currentLang !== 'zh') return text;
return text
.replace(/_\(Cancelled by user\)_/g, '_(用户已中止)_')
.replace(/_\(Cancelled\)_/g, '_(已中止)_');
}
function createBotMessageEl(content, timestamp, requestId, msg) {
const el = document.createElement('div');
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
if (requestId) el.dataset.requestId = requestId;
let stepsHtml = '';
let displayContent = content;
let displayContent = localizeCancelMarker(content);
if (msg && msg.steps && msg.steps.length > 0) {
// New format: ordered steps with interleaved content

View File

@@ -93,6 +93,15 @@ def _require_auth():
json.dumps({"status": "error", "message": "Unauthorized"}))
# Localized text for /cancel system replies. Web is the only channel that
# honors a per-request `lang`; other channels reply in Chinese by default.
def _cancel_reply_text(cancelled: int, lang: str) -> str:
en = lang.startswith("en")
if cancelled > 0:
return "🛑 Cancelled." if en else "🛑 已中止"
return "Nothing to cancel." if en else "当前没有可中止的任务。"
def _get_upload_dir() -> str:
from common.utils import expand_path
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
@@ -437,6 +446,18 @@ class WebChannel(ChatChannel):
"timestamp": time.time(),
})
elif event_type == "agent_cancelled":
# Push an explicit cancelled SSE event so the frontend
# marks the bubble as stopped. A trailing "done" still
# arrives with the partial answer.
final_response = data.get("final_response", "")
q.put({
"type": "cancelled",
"content": final_response,
"request_id": request_id,
"timestamp": time.time(),
})
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),
@@ -756,6 +777,25 @@ class WebChannel(ChatChannel):
# desire_rtype concept used by other channels).
is_voice_input = bool(json_data.get('is_voice', False))
# Fast path for /cancel: bypass the session queue and SSE setup.
# Web frontend (stream=true) only listens to SSE, so we return an
# inline_reply payload to be rendered synchronously.
stripped_prompt = (prompt or "").strip().lower()
if stripped_prompt == "/cancel":
from agent.protocol import get_cancel_registry
cancelled = get_cancel_registry().cancel_session(session_id)
lang = (json_data.get('lang') or 'zh').lower()
msg_text = _cancel_reply_text(cancelled, lang)
logger.info(
f"[WebChannel] /cancel fast-path: session={session_id}, cancelled={cancelled}, lang={lang}"
)
return json.dumps({
"status": "success",
"request_id": "",
"stream": False,
"inline_reply": msg_text,
})
# Append file references to the prompt (same format as QQ channel)
if attachments:
file_refs = []
@@ -862,6 +902,11 @@ class WebChannel(ChatChannel):
if itype == "done":
post_done = True
post_deadline = time.time() + POST_DONE_TAIL_SECONDS
elif itype == "cancelled":
# Close SSE tail quickly after cancel; don't wait for the
# full TTS tail since the user already pressed Stop.
post_done = True
post_deadline = time.time() + 3
elif itype == "voice_attach":
# WSGI buffers the previous chunk until the next yield;
# shrink the tail so the generator wakes up quickly to
@@ -872,6 +917,59 @@ class WebChannel(ChatChannel):
finally:
self.sse_queues.pop(request_id, None)
def cancel_request(self):
"""
Cancel an in-flight agent run.
Body: {"request_id": "...", "session_id": "..."}
Either field is sufficient; request_id is preferred when known.
Always returns success even when nothing was running, so the
client's UX is idempotent.
"""
try:
from agent.protocol import get_cancel_registry
data = web.data()
try:
json_data = json.loads(data) if data else {}
except Exception:
json_data = {}
request_id = (json_data.get("request_id") or "").strip()
session_id = (json_data.get("session_id") or "").strip()
lang = (json_data.get("lang") or "zh").lower()
registry = get_cancel_registry()
cancelled = 0
if request_id:
if registry.cancel_request(request_id):
cancelled = 1
if cancelled == 0 and session_id:
cancelled = registry.cancel_session(session_id)
if request_id and request_id in self.sse_queues:
self.sse_queues[request_id].put({
"type": "cancelled",
"content": "Cancelled" if lang.startswith("en") else "已中止",
"request_id": request_id,
"timestamp": time.time(),
})
logger.info(
f"[WebChannel] cancel request: request_id={request_id!r}, "
f"session_id={session_id!r}, cancelled={cancelled}"
)
return json.dumps({
"status": "success",
"cancelled": cancelled,
})
except Exception as e:
logger.error(f"[WebChannel] cancel_request error: {e}")
return json.dumps({"status": "error", "message": str(e)})
def poll_response(self):
"""
Poll for responses using the session_id.
@@ -967,6 +1065,7 @@ class WebChannel(ChatChannel):
'/api/voice/tts', 'VoiceTtsHandler',
'/poll', 'PollHandler',
'/stream', 'StreamHandler',
'/cancel', 'CancelHandler',
'/chat', 'ChatHandler',
'/config', 'ConfigHandler',
'/api/models', 'ModelsHandler',
@@ -1240,6 +1339,12 @@ class PollHandler:
return WebChannel().poll_response()
class CancelHandler:
def POST(self):
_require_auth()
return WebChannel().cancel_request()
class StreamHandler:
def GET(self):
_require_auth()

View File

@@ -440,6 +440,17 @@ class WecomBotChannel(ChatChannel):
state["current"] = ""
_push_stream(state, force=True)
elif event_type == "agent_cancelled":
# Flush partial output and strip trailing "---" separator
# left over from previous turn, to avoid a dangling divider.
if state["current"]:
state["committed"] += state["current"]
state["current"] = ""
state["committed"] = state["committed"].rstrip()
if state["committed"].endswith("---"):
state["committed"] = state["committed"][:-3].rstrip()
_push_stream(state, force=True)
return on_event
# ------------------------------------------------------------------