feat: stream Bash progress and guard message actions during replies

This commit is contained in:
yangziyu-hhh
2026-06-10 13:46:50 +08:00
parent f5caba81d6
commit 402e2bfee0
6 changed files with 207 additions and 31 deletions

View File

@@ -1174,10 +1174,21 @@ class AgentStreamExecutor:
# Set tool context # Set tool context
tool.model = self.model tool.model = self.model
tool.context = self.agent tool.context = self.agent
tool.progress_callback = lambda message: self._emit_event(
"tool_execution_progress",
{
"tool_call_id": tool_id,
"tool_name": tool_name,
"message": message,
}
)
# Execute tool # Execute tool
start_time = time.time() start_time = time.time()
try:
result: ToolResult = tool.execute_tool(arguments) result: ToolResult = tool.execute_tool(arguments)
finally:
tool.progress_callback = None
execution_time = time.time() - start_time execution_time = time.time() - start_time
result_dict = { result_dict = {

View File

@@ -38,6 +38,16 @@ class BaseTool:
description: str = "Base tool" description: str = "Base tool"
params: dict = {} # Store JSON Schema params: dict = {} # Store JSON Schema
model: Optional[Any] = None # LLM model instance, type depends on bot implementation model: Optional[Any] = None # LLM model instance, type depends on bot implementation
progress_callback = None
def report_progress(self, message: str):
callback = getattr(self, "progress_callback", None)
if not callback:
return
try:
callback(str(message))
except Exception as e:
logger.debug(f"[{self.name}] progress callback failed: {e}")
@classmethod @classmethod
def get_json_schema(cls) -> dict: def get_json_schema(cls) -> dict:

View File

@@ -4,9 +4,12 @@ Bash tool - Execute bash commands
import os import os
import re import re
import signal
import sys import sys
import subprocess import subprocess
import tempfile import tempfile
import threading
import time
from typing import Dict, Any from typing import Dict, Any
from agent.tools.base_tool import BaseTool, ToolResult from agent.tools.base_tool import BaseTool, ToolResult
@@ -19,6 +22,8 @@ class Bash(BaseTool):
"""Tool for executing bash commands""" """Tool for executing bash commands"""
_IS_WIN = sys.platform == "win32" _IS_WIN = sys.platform == "win32"
_PROGRESS_MAX_BYTES = 32 * 1024
_PROGRESS_INTERVAL = 0.5
name: str = "bash" name: str = "bash"
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
@@ -113,17 +118,11 @@ SAFETY:
if command and not command.strip().lower().startswith("chcp"): if command and not command.strip().lower().startswith("chcp"):
command = f"chcp 65001 >nul 2>&1 && {command}" command = f"chcp 65001 >nul 2>&1 && {command}"
result = subprocess.run( result = self._run_streaming(
command, command,
shell=True, timeout,
cwd=self.cwd, env,
stdout=subprocess.PIPE, dotenv_vars,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
env=env,
) )
logger.debug(f"[Bash] Exit code: {result.returncode}") logger.debug(f"[Bash] Exit code: {result.returncode}")
@@ -236,6 +235,93 @@ SAFETY:
except Exception as e: except Exception as e:
return ToolResult.fail(f"Error executing command: {str(e)}") return ToolResult.fail(f"Error executing command: {str(e)}")
def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict):
process = subprocess.Popen(
command,
shell=True,
cwd=self.cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
start_new_session=not self._IS_WIN,
)
stdout_chunks, stderr_chunks = [], []
recent = bytearray()
recent_lock = threading.Lock()
def drain(stream, chunks):
while True:
chunk = os.read(stream.fileno(), 4096)
if not chunk:
break
chunks.append(chunk)
with recent_lock:
recent.extend(chunk)
if len(recent) > self._PROGRESS_MAX_BYTES:
del recent[:-self._PROGRESS_MAX_BYTES]
readers = [
threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True),
threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True),
]
for reader in readers:
reader.start()
started = time.monotonic()
last_reported_at = started
last_snapshot = None
try:
while process.poll() is None:
now = time.monotonic()
elapsed = now - started
if elapsed >= timeout:
self._kill_process(process)
raise subprocess.TimeoutExpired(command, timeout)
if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL:
with recent_lock:
snapshot = bytes(recent).decode("utf-8", errors="replace")
snapshot = self._redact_progress(snapshot, dotenv_vars)
if snapshot and snapshot != last_snapshot:
self.report_progress(snapshot)
last_snapshot = snapshot
last_reported_at = now
time.sleep(0.1)
finally:
if process.poll() is None:
self._kill_process(process)
process.wait()
for reader in readers:
reader.join()
from types import SimpleNamespace
return SimpleNamespace(
returncode=process.returncode,
stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"),
)
def _kill_process(self, process):
if self._IS_WIN:
process.kill()
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except PermissionError:
process.kill()
@staticmethod
def _redact_progress(text: str, dotenv_vars: dict) -> str:
text = re.sub(
r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+',
lambda match: f"{match.group(1)}=[REDACTED]",
text,
)
for value in dotenv_vars.values():
value = str(value or "")
if len(value) >= 6:
text = text.replace(value, "[REDACTED]")
return text
def _get_safety_warning(self, command: str) -> str: def _get_safety_warning(self, command: str) -> str:
""" """
Get safety warning for absolutely catastrophic commands only. Get safety warning for absolutely catastrophic commands only.

View File

@@ -605,6 +605,18 @@
color: inherit; color: inherit;
} }
.tool-error-text { color: #f87171; } .tool-error-text { color: #f87171; }
.tool-live-output:empty { display: none; }
.tool-streaming .tool-live-output:not(:empty)::after {
content: ' ';
display: inline-block;
width: 0.45em;
height: 1em;
margin-left: 0.15em;
vertical-align: -0.15em;
background: currentColor;
animation: tool-cursor-blink 1s steps(1) infinite;
}
@keyframes tool-cursor-blink { 50% { opacity: 0; } }
/* Log level highlighting */ /* Log level highlighting */
.log-line { display: block; } .log-line { display: block; }
@@ -1571,3 +1583,9 @@
.delete-msg-btn:hover { .delete-msg-btn:hover {
color: #ef4444 !important; color: #ef4444 !important;
} }
.edit-msg-btn:disabled,
.delete-msg-btn:disabled {
cursor: not-allowed !important;
opacity: 0.35 !important;
}

View File

@@ -889,6 +889,26 @@ let pollGeneration = 0; // incremented on each restart to cancel stale poll lo
let loadingContainers = {}; let loadingContainers = {};
let activeStreams = {}; // request_id -> EventSource let activeStreams = {}; // request_id -> EventSource
let sessionActiveRequest = {}; // session_id -> request_id (in-flight stream per session) let sessionActiveRequest = {}; // session_id -> request_id (in-flight stream per session)
function isCurrentSessionConversationActive() {
return !!sessionActiveRequest[sessionId];
}
function updateEditButtonsState() {
const active = isCurrentSessionConversationActive();
document.querySelectorAll('.edit-msg-btn, .delete-msg-btn').forEach(btn => {
btn.disabled = active;
if (btn.classList.contains('edit-msg-btn')) {
btn.title = active
? 'Reply is being generated; editing is temporarily unavailable.'
: (currentLang === 'zh' ? '编辑消息' : 'Edit Message');
} else {
btn.title = active
? 'Reply is being generated; deletion is temporarily unavailable.'
: t('delete_message_title');
}
});
}
let streamBuffers = {}; // request_id -> { items: [event...], timestamp } for re-attach replay let streamBuffers = {}; // request_id -> { items: [event...], timestamp } for re-attach replay
let isComposing = false; let isComposing = false;
let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} }; let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} };
@@ -1177,6 +1197,7 @@ messagesDiv.addEventListener('click', (e) => {
const editBtn = e.target.closest('.edit-msg-btn'); const editBtn = e.target.closest('.edit-msg-btn');
if (editBtn) { if (editBtn) {
e.preventDefault(); e.preventDefault();
if (isCurrentSessionConversationActive()) return;
const msgRoot = editBtn.closest('.user-message-group'); const msgRoot = editBtn.closest('.user-message-group');
if (msgRoot) editUserMessage(msgRoot); if (msgRoot) editUserMessage(msgRoot);
return; return;
@@ -1197,6 +1218,7 @@ messagesDiv.addEventListener('click', (e) => {
const deleteBtn = e.target.closest('.delete-msg-btn'); const deleteBtn = e.target.closest('.delete-msg-btn');
if (deleteBtn) { if (deleteBtn) {
e.preventDefault(); e.preventDefault();
if (isCurrentSessionConversationActive()) return;
const userMsgEl = deleteBtn.closest('.user-message-group'); const userMsgEl = deleteBtn.closest('.user-message-group');
if (!userMsgEl) return; if (!userMsgEl) return;
@@ -1993,6 +2015,7 @@ function copyToClipboard(text) {
// Edit user message: extract content, remove this and subsequent messages, fill input // Edit user message: extract content, remove this and subsequent messages, fill input
async function editUserMessage(msgEl) { async function editUserMessage(msgEl) {
if (isCurrentSessionConversationActive()) return;
const rawContent = msgEl.dataset.rawContent; const rawContent = msgEl.dataset.rawContent;
if (!rawContent) return; if (!rawContent) return;
@@ -2043,9 +2066,9 @@ async function editUserMessage(msgEl) {
// Fill input with the original content // Fill input with the original content
chatInput.value = rawContent; chatInput.value = rawContent;
chatInput.style.height = 'auto'; chatInput.dispatchEvent(new Event("input", { bubbles: true }));
chatInput.style.height = chatInput.scrollHeight + 'px';
chatInput.focus(); chatInput.focus();
chatInput.selectionStart = chatInput.selectionEnd = chatInput.value.length;
scrollChatToBottom(); scrollChatToBottom();
} }
@@ -2250,7 +2273,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) {
let contentEl = null; // .answer-content (final streaming answer) let contentEl = null; // .answer-content (final streaming answer)
let mediaEl = null; // .media-content (images & file attachments) let mediaEl = null; // .media-content (images & file attachments)
let accumulatedText = ''; let accumulatedText = '';
let currentToolEl = null; const toolElements = new Map();
let currentReasoningEl = null; // live reasoning bubble let currentReasoningEl = null; // live reasoning bubble
let reasoningText = ''; let reasoningText = '';
let reasoningStartTime = 0; let reasoningStartTime = 0;
@@ -2265,12 +2288,14 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) {
const ownerSession = sessionId; const ownerSession = sessionId;
const isActive = () => ownerSession === sessionId; const isActive = () => ownerSession === sessionId;
sessionActiveRequest[ownerSession] = requestId; sessionActiveRequest[ownerSession] = requestId;
updateEditButtonsState();
// Per-request event buffer used to rebuild the bubble on re-attach. // Per-request event buffer used to rebuild the bubble on re-attach.
const buffer = streamBuffers[requestId] || { items: [], timestamp }; const buffer = streamBuffers[requestId] || { items: [], timestamp };
streamBuffers[requestId] = buffer; streamBuffers[requestId] = buffer;
const clearOwnerRequest = () => { const clearOwnerRequest = () => {
if (sessionActiveRequest[ownerSession] === requestId) { if (sessionActiveRequest[ownerSession] === requestId) {
delete sessionActiveRequest[ownerSession]; delete sessionActiveRequest[ownerSession];
updateEditButtonsState();
} }
delete streamBuffers[requestId]; delete streamBuffers[requestId];
}; };
@@ -2419,10 +2444,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) {
contentEl.innerHTML = ''; contentEl.innerHTML = '';
// Add tool execution indicator (collapsible) // Add tool execution indicator (collapsible)
currentToolEl = document.createElement('div'); const toolEl = document.createElement('div');
currentToolEl.className = 'agent-step agent-tool-step'; toolEl.className = 'agent-step agent-tool-step tool-streaming';
const argsStr = formatToolArgs(item.arguments || {}); const argsStr = formatToolArgs(item.arguments || {});
currentToolEl.innerHTML = ` toolEl.innerHTML = `
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')"> <div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
<i class="fas fa-cog fa-spin text-primary-400 flex-shrink-0 tool-icon"></i> <i class="fas fa-cog fa-spin text-primary-400 flex-shrink-0 tool-icon"></i>
<span class="tool-name">${item.tool}</span> <span class="tool-name">${item.tool}</span>
@@ -2433,36 +2458,51 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) {
<div class="tool-detail-label">Input</div> <div class="tool-detail-label">Input</div>
<pre class="tool-detail-content">${argsStr}</pre> <pre class="tool-detail-content">${argsStr}</pre>
</div> </div>
<div class="tool-detail-section tool-output-section"></div> <div class="tool-detail-section tool-output-section">
<div class="tool-detail-label tool-output-label">Output</div>
<pre class="tool-detail-content tool-live-output"></pre>
</div>
</div>`; </div>`;
stepsEl.appendChild(currentToolEl); stepsEl.appendChild(toolEl);
toolElements.set(item.tool_call_id, toolEl);
scrollChatToBottom(); scrollChatToBottom();
} else if (item.type === 'tool_progress') {
const toolEl = toolElements.get(item.tool_call_id);
if (toolEl) {
toolEl.classList.add('expanded');
toolEl.querySelector('.tool-live-output').textContent = String(item.content || '');
scrollChatToBottom();
}
} else if (item.type === 'tool_end') { } else if (item.type === 'tool_end') {
if (currentToolEl) { const toolEl = toolElements.get(item.tool_call_id);
if (toolEl) {
const isError = item.status !== 'success'; const isError = item.status !== 'success';
const icon = currentToolEl.querySelector('.tool-icon'); const icon = toolEl.querySelector('.tool-icon');
icon.className = isError icon.className = isError
? 'fas fa-times text-red-400 flex-shrink-0 tool-icon' ? 'fas fa-times text-red-400 flex-shrink-0 tool-icon'
: 'fas fa-check text-primary-400 flex-shrink-0 tool-icon'; : 'fas fa-check text-primary-400 flex-shrink-0 tool-icon';
// Show execution time // Show execution time
const nameEl = currentToolEl.querySelector('.tool-name'); const nameEl = toolEl.querySelector('.tool-name');
if (item.execution_time !== undefined) { if (item.execution_time !== undefined) {
nameEl.innerHTML += ` <span class="tool-time">${item.execution_time}s</span>`; nameEl.innerHTML += ` <span class="tool-time">${item.execution_time}s</span>`;
} }
// Fill output section // Fill output section
const outputSection = currentToolEl.querySelector('.tool-output-section'); const outputLabel = toolEl.querySelector('.tool-output-label');
if (outputSection && item.result) { const outputEl = toolEl.querySelector('.tool-live-output');
outputSection.innerHTML = ` if (outputLabel) outputLabel.textContent = isError ? 'Error' : 'Output';
<div class="tool-detail-label">${isError ? 'Error' : 'Output'}</div> if (outputEl) {
<pre class="tool-detail-content ${isError ? 'tool-error-text' : ''}">${escapeHtml(String(item.result))}</pre>`; outputEl.textContent = item.result ? String(item.result) : '';
outputEl.classList.toggle('tool-error-text', isError);
} }
if (isError) currentToolEl.classList.add('tool-failed'); toolEl.classList.remove('tool-streaming');
currentToolEl = null; if (isError) toolEl.classList.add('tool-failed');
toolElements.delete(item.tool_call_id);
} }
} else if (item.type === 'image') { } else if (item.type === 'image') {
@@ -3256,6 +3296,7 @@ function loadHistory(page) {
const sentinel = document.getElementById('history-load-more'); const sentinel = document.getElementById('history-load-more');
const insertBefore = sentinel ? sentinel.nextSibling : messagesDiv.firstChild; const insertBefore = sentinel ? sentinel.nextSibling : messagesDiv.firstChild;
messagesDiv.insertBefore(fragment, insertBefore); messagesDiv.insertBefore(fragment, insertBefore);
updateEditButtonsState();
// Manage the "load more" sentinel at the very top // Manage the "load more" sentinel at the very top
if (data.has_more) { if (data.has_more) {
@@ -3711,6 +3752,7 @@ function switchSession(newSessionId) {
// Switching back re-attaches and resumes live streaming. // Switching back re-attaches and resumes live streaming.
sessionId = newSessionId; sessionId = newSessionId;
updateEditButtonsState();
localStorage.setItem(SESSION_ID_KEY, sessionId); localStorage.setItem(SESSION_ID_KEY, sessionId);
historyPage = 0; historyPage = 0;

View File

@@ -432,7 +432,15 @@ class WebChannel(ChatChannel):
elif event_type == "tool_execution_start": elif event_type == "tool_execution_start":
tool_name = data.get("tool_name", "tool") tool_name = data.get("tool_name", "tool")
arguments = data.get("arguments", {}) arguments = data.get("arguments", {})
q.put({"type": "tool_start", "tool": tool_name, "arguments": arguments}) q.put({"type": "tool_start", "tool_call_id": data.get("tool_call_id"), "tool": tool_name, "arguments": arguments})
elif event_type == "tool_execution_progress":
q.put({
"type": "tool_progress",
"tool_call_id": data.get("tool_call_id"),
"tool": data.get("tool_name", "tool"),
"content": str(data.get("message", ""))[-32 * 1024:],
})
elif event_type == "tool_execution_end": elif event_type == "tool_execution_end":
tool_name = data.get("tool_name", "tool") tool_name = data.get("tool_name", "tool")
@@ -445,6 +453,7 @@ class WebChannel(ChatChannel):
result_str = result_str[:2000] + "" result_str = result_str[:2000] + ""
q.put({ q.put({
"type": "tool_end", "type": "tool_end",
"tool_call_id": data.get("tool_call_id"),
"tool": tool_name, "tool": tool_name,
"status": status, "status": status,
"result": result_str, "result": result_str,