fix: 修复对话计时器持久化、子智能体工具美化与状态查询,统一后台通知池

- 新增 server/work_timer.py,在对话真正空闲(无前台任务、无后台子智能体/后台命令/压缩)时持久化 work_timer,并同步内存副本,解决刷新后计时器回退问题。
- server/chat_flow_task_main.py / chat_flow.py / tasks/models.py 在任务结束/取消时按空闲判定决定是否持久化。
- 前端 lifecycle.ts 在仍有后台任务时不提前停止计时器。
- 子智能体工具(create/terminate/get_status)渲染美化,task 参数放在顶部元信息区。
- 子智能体状态查询支持返回「已完成」「已终止」「不存在」;修复 wait_for_completion 在 final_result 就绪前返回导致的「ID 被占用」误报。
- 统一后台完成通知池 poll_completion_notifications,合并子智能体与后台 run_command 两路轮询,避免逐条触发工作循环与单工作区互斥冲突。
- 删除本次新增的各类 debug_log / notify_pool_log 调用及辅助脚本。
This commit is contained in:
JOJO 2026-06-25 04:16:44 +08:00
parent f3e166e258
commit 72a49a7c8d
19 changed files with 908 additions and 503 deletions

View File

@ -1704,8 +1704,26 @@ class MainTerminalToolsExecutionMixin:
task_id=task_id, task_id=task_id,
timeout_seconds=arguments.get("timeout_seconds") timeout_seconds=arguments.get("timeout_seconds")
) )
# 合并结果 # 合并结果:保留创建元数据,使用执行结果作为主体展示,
result.update(wait_result) # 避免 wait_result 里的 success=False + 旧 message 导致
# 「create_sub_agent 失败:子智能体 X 已创建」的误导性文案。
creation_meta = {
"agent_id": result.get("agent_id"),
"task_id": result.get("task_id"),
"deliverables_dir": result.get("deliverables_dir"),
"run_in_background": False,
}
execution_message = (
wait_result.get("message")
or wait_result.get("system_message")
or result.get("message")
)
result = {
**result,
**wait_result,
**creation_meta,
"message": execution_message,
}
# 阻塞式执行不需要额外插入 system 消息 # 阻塞式执行不需要额外插入 system 消息
result.pop("system_message", None) result.pop("system_message", None)
# 标记已通知,避免后续轮询再插入 system 消息 # 标记已通知,避免后续轮询再插入 system 消息

View File

@ -91,7 +91,6 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"shallow_compress_keep_user_turn_tools": None, # 保留最近N次用户输入后的工具不压缩默认3 "shallow_compress_keep_user_turn_tools": None, # 保留最近N次用户输入后的工具不压缩默认3
"deep_compress_trigger_tokens": None, "deep_compress_trigger_tokens": None,
"deep_compress_form": "file", # 深压缩形式file-生成文件 / inject-直接注入文件全文 "deep_compress_form": "file", # 深压缩形式file-生成文件 / inject-直接注入文件全文
"deep_compress_behavior": "continue", # 手动压缩行为continue-注入并触发请求 / wait-仅插入等待用户
"silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启) "silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启)
"enhanced_tool_display": True, # 增强工具显示 "enhanced_tool_display": True, # 增强工具显示
"compact_message_display": "full", # 简略消息显示full-完整原始内容 / brief-一行概要 "compact_message_display": "full", # 简略消息显示full-完整原始内容 / brief-一行概要
@ -396,13 +395,6 @@ def sanitize_personalization_payload(
else: else:
base["deep_compress_form"] = "file" base["deep_compress_form"] = "file"
# 手动压缩行为continue注入并触发请求/ wait仅插入等待用户
deep_behavior = data.get("deep_compress_behavior", base.get("deep_compress_behavior"))
if isinstance(deep_behavior, str) and deep_behavior.strip().lower() in ("continue", "wait"):
base["deep_compress_behavior"] = deep_behavior.strip().lower()
else:
base["deep_compress_behavior"] = "continue"
if "shallow_compress_trigger_tool_calls_interval" in data: if "shallow_compress_trigger_tool_calls_interval" in data:
base["shallow_compress_trigger_tool_calls_interval"] = _sanitize_optional_int( base["shallow_compress_trigger_tool_calls_interval"] = _sanitize_optional_int(
data.get("shallow_compress_trigger_tool_calls_interval"), data.get("shallow_compress_trigger_tool_calls_interval"),

View File

@ -269,11 +269,28 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
while time.time() < deadline: while time.time() < deadline:
self.reconcile_task_states() self.reconcile_task_states()
if task.get("status") in TERMINAL_STATUSES: status = task.get("status")
return task.get("final_result") or {"success": False, "status": task.get("status")}
# 已到达终态:返回最终结果(持续 reconcile 直到 final_result 就绪)
if status in TERMINAL_STATUSES or status == "terminated":
if task.get("final_result"):
return task["final_result"]
# 终态但 final_result 尚未写入,短暂等待后重试
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL)
self.reconcile_task_states()
if task.get("final_result"):
return task["final_result"]
return {"success": False, "status": status, "message": "子智能体已结束,但未获取到结果。"}
# asyncio Task 已结束但状态可能还没同步:等待 final_result 就绪
if running_task and running_task.done(): if running_task and running_task.done():
self.reconcile_task_states() self.reconcile_task_states()
return task.get("final_result") or {"success": False, "status": task.get("status")} if task.get("final_result"):
return task["final_result"]
# 结果尚未落盘,继续轮询,避免把「已创建」误判为失败
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL)
continue
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL) time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL)
return self._handle_timeout(task) return self._handle_timeout(task)
@ -317,23 +334,44 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
*, *,
agent_ids: Optional[List[int]] = None, agent_ids: Optional[List[int]] = None,
) -> Dict: ) -> Dict:
"""获取指定子智能体的详细状态。""" """获取指定子智能体的详细状态。
对于已结束completed/failed/timeout/terminated的子智能体同样返回其
最终状态而不是返回不存在
"""
if not agent_ids: if not agent_ids:
return {"success": False, "error": "必须指定至少一个agent_id"} return {"success": False, "error": "必须指定至少一个agent_id"}
def _find_task_by_agent_id(aid: int):
# 先查运行中/待运行的任务
task = self._select_task(None, aid)
if task:
return task
# 再查已结束的任务(按创建时间取最新一条)
candidates = [
t for t in self.tasks.values()
if t.get("agent_id") == aid
]
if not candidates:
return None
candidates.sort(key=lambda item: item.get("created_at", 0), reverse=True)
return candidates[0]
results = [] results = []
for agent_id in agent_ids: for agent_id in agent_ids:
task = self._select_task(None, agent_id) task = _find_task_by_agent_id(agent_id)
if not task: if not task:
results.append({ results.append({
"agent_id": agent_id, "agent_id": agent_id,
"found": False, "found": False,
"error": "未找到对应的子智能体任务" "error": "子智能体不存在",
}) })
continue continue
if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}): status = task.get("status")
if status not in TERMINAL_STATUSES.union({"terminated"}):
self._check_task_status(task) self._check_task_status(task)
status = task.get("status")
stats = {} stats = {}
stats_file = Path(task.get("stats_file", "")) stats_file = Path(task.get("stats_file", ""))
@ -348,7 +386,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
"agent_id": agent_id, "agent_id": agent_id,
"found": True, "found": True,
"task_id": task["task_id"], "task_id": task["task_id"],
"status": task["status"], "status": status,
"summary": task.get("summary"), "summary": task.get("summary"),
"created_at": task.get("created_at"), "created_at": task.get("created_at"),
"updated_at": task.get("updated_at"), "updated_at": task.get("updated_at"),

View File

@ -57,6 +57,7 @@ from config.model_profiles import get_model_context_window, get_model_profile
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker
from .work_timer import finalize_conversation_work_timer
from .utils_common import ( from .utils_common import (
build_review_lines, build_review_lines,
debug_log, debug_log,
@ -224,37 +225,15 @@ def process_message_task(terminal: WebTerminal, message: str, images, sender, cl
f"has_running_sub_agents={has_running_sub_agents}, " f"has_running_sub_agents={has_running_sub_agents}, "
f"has_running_background_commands={has_running_background_commands}" f"has_running_background_commands={has_running_background_commands}"
) )
# 取消时立即把当前对话最后一条 working 的用户消息 work_timer 标记为完成, # 取消时:仅当对话真正空闲(没有后台子智能体/后台命令还在跑)
# 避免刷新页面后仍显示"工作中" # 才把 work_timer 标记为完成,否则后台工作继续期间计时器应保持运行
try: try:
if terminal and getattr(terminal, "context_manager", None): if terminal and conversation_id:
cm = terminal.context_manager finalized = finalize_conversation_work_timer(
history = getattr(cm, "conversation_history", None) or [] terminal,
for msg in reversed(history): conversation_id,
if not isinstance(msg, dict) or msg.get("role") != "user": finished_at=datetime.now().isoformat(),
continue
metadata = msg.get("metadata") or {}
timer = metadata.get("work_timer")
if not isinstance(timer, dict) or timer.get("status") != "working":
continue
started_at = timer.get("started_at") or msg.get("timestamp") or datetime.now().isoformat()
try:
start_ts = datetime.fromisoformat(str(started_at).replace("Z", "+00:00")).timestamp()
duration_ms = int(max(0.0, (time.time() - start_ts) * 1000.0))
except Exception:
duration_ms = timer.get("duration_ms", 0) or 0
timer.update({
"status": "completed",
"started_at": started_at,
"finished_at": datetime.now().isoformat(),
"duration_ms": duration_ms,
})
cm.auto_save_conversation(force=True)
debug_log(
f"[ChatFlow] 取消时已将 work_timer 标记为完成: "
f"conversation_id={getattr(cm, 'current_conversation_id', None)}, started_at={started_at}"
) )
break
except Exception as exc: except Exception as exc:
debug_log(f"[ChatFlow] 取消时标记 work_timer 完成失败: {exc}") debug_log(f"[ChatFlow] 取消时标记 work_timer 完成失败: {exc}")
# task_stopped 事件统一在 _run_chat_task finally 中发送,避免重复 # task_stopped 事件统一在 _run_chat_task finally 中发送,避免重复

View File

@ -53,6 +53,7 @@ from config.model_profiles import get_model_context_window, get_model_profile
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker
from .work_timer import finalize_conversation_work_timer
from .utils_common import ( from .utils_common import (
build_review_lines, build_review_lines,
debug_log, debug_log,
@ -165,7 +166,12 @@ def _user_message_ui_defaults(source: str, *, auto_user_message_event: bool = Fa
return {"visibility": "hidden", "starts_work": False} return {"visibility": "hidden", "starts_work": False}
if normalized in {"guidance", "notify"}: if normalized in {"guidance", "notify"}:
return {"visibility": "compact", "starts_work": False} return {"visibility": "compact", "starts_work": False}
if normalized in {"goal_review", "compression", "compression_handoff", "sub_agent", "background_command"}: # 后台完成通知 / 运行期压缩续接:本质属于当前这轮工作的延续,
# 不应暂停上一轮计时器、也不应开启新的工作头与计时器。
if normalized in {"sub_agent", "background_command", "compression", "compression_handoff"}:
return {"visibility": "compact", "starts_work": False}
# 目标审核续命:确实开启新一轮工作,保持 starts_work=True。
if normalized == "goal_review":
return {"visibility": "compact", "starts_work": True} return {"visibility": "compact", "starts_work": True}
if normalized == "presend": if normalized == "presend":
return {"visibility": "chat", "starts_work": True} return {"visibility": "chat", "starts_work": True}
@ -342,6 +348,52 @@ def _record_hidden_versioning_checkpoint_after_run(
debug_log(f"[Versioning] 记录快照异常: {exc}") debug_log(f"[Versioning] 记录快照异常: {exc}")
def _persist_and_echo_preceding_notice(
*,
web_terminal,
sender,
conversation_id: str,
message: str,
payload: Dict[str, Any],
) -> None:
"""预写一条「前置完成通知」:写入对话历史 + socketio 回显(不触发新一轮工作)。
用于通知池一次取出多条时 N-1 条随同一后续任务一起呈现
这些通知必须落历史否则模型看不到刷新后也会丢失
"""
message = (message or "").strip()
if not message:
return
src = str(payload.get("message_source") or "sub_agent").strip().lower()
if src not in _VALID_USER_MESSAGE_SOURCES:
src = "sub_agent"
ui_defaults = _user_message_ui_defaults(src, auto_user_message_event=True)
# 前置通知渲染为完成态气泡:不开启独立工作计时器
ui_defaults = {**ui_defaults, "starts_work": False}
metadata = {
"message_source": src,
"is_auto_generated": True,
"auto_message_type": "completion_notice",
**ui_defaults,
}
try:
cm = getattr(web_terminal, "context_manager", None)
if cm is not None:
cm.add_conversation("user", message, metadata=metadata)
except Exception as exc:
echo_payload = {
"message": message,
"conversation_id": conversation_id,
"message_source": src,
**ui_defaults,
**{k: v for k, v in (payload or {}).items() if k not in {"message", "conversation_id"}},
}
echo_payload["starts_work"] = False
echo_payload["metadata"] = {**metadata}
sender("user_message", echo_payload)
except Exception as exc:
pass
async def _dispatch_completion_user_notice( async def _dispatch_completion_user_notice(
*, *,
web_terminal, web_terminal,
@ -352,9 +404,16 @@ async def _dispatch_completion_user_notice(
conversation_id: str, conversation_id: str,
user_message: str, user_message: str,
extra_payload: Optional[Dict[str, Any]] = None, extra_payload: Optional[Dict[str, Any]] = None,
preceding_notices: Optional[List[Dict[str, Any]]] = None,
): ):
"""复用子智能体完成后的 user 代发机制。""" """复用子智能体完成后的 user 代发机制。
preceding_notices本批通知池里除最后一条以外的前置通知列表
每项为 {"message": str, "payload": dict}这些会先于触发消息写入历史/事件流
但不各自触发新一轮工作仅最后一条 user_message 作为后续任务的触发消息
"""
extra_payload = extra_payload or {} extra_payload = extra_payload or {}
preceding_notices = preceding_notices or []
message_source = str(extra_payload.get("message_source") or "").strip().lower() message_source = str(extra_payload.get("message_source") or "").strip().lower()
if not message_source: if not message_source:
if extra_payload.get("runtime_mode_notice"): if extra_payload.get("runtime_mode_notice"):
@ -367,6 +426,18 @@ async def _dispatch_completion_user_notice(
message_source = "sub_agent" message_source = "sub_agent"
if message_source not in _VALID_USER_MESSAGE_SOURCES: if message_source not in _VALID_USER_MESSAGE_SOURCES:
message_source = "user" message_source = "user"
# 先把前置通知写入历史并 socketio 回显(在线客户端即时可见)。
# 轮询客户端则通过后续任务事件流回放(见 _run_chat_task 的 preceding_user_notices 注入)。
for item in preceding_notices:
_persist_and_echo_preceding_notice(
web_terminal=web_terminal,
sender=sender,
conversation_id=conversation_id,
message=str(item.get("message") or ""),
payload=dict(item.get("payload") or {}),
)
try: try:
from .tasks import task_manager from .tasks import task_manager
workspace_id = getattr(workspace, "workspace_id", None) or "default" workspace_id = getattr(workspace, "workspace_id", None) or "default"
@ -394,6 +465,16 @@ async def _dispatch_completion_user_notice(
**dict(extra_payload or {}), **dict(extra_payload or {}),
**ui_defaults, **ui_defaults,
} }
# 轮询客户端通过后续任务事件流回放前置通知(在线客户端已由上面的 socketio 回显覆盖)。
if preceding_notices:
session_data["preceding_user_notices"] = [
{
"message": str(item.get("message") or ""),
"payload": dict(item.get("payload") or {}),
}
for item in preceding_notices
if str(item.get("message") or "").strip()
]
rec = task_manager.create_chat_task( rec = task_manager.create_chat_task(
username, username,
workspace_id, workspace_id,
@ -468,320 +549,247 @@ def _build_shared_waiting_payload(items: List[Dict[str, Any]]) -> Dict[str, Any]
'tasks': normalized, 'tasks': normalized,
} }
async def poll_sub_agent_completion(*, web_terminal, workspace, conversation_id, client_sid, username): def _build_sub_agent_notice_text(update: Dict[str, Any], *, summary_override: Optional[str] = None) -> str:
"""后台轮询子智能体完成状态,完成后触发新一轮对话""" """根据子智能体完成结果构建一条 user 通知文本(统一 [系统通知|sub_agent] 前缀)。
update 通常是 task final_result其中 system_message 已经是一条完整的格式化通知
/ 前缀统计运行秒数summary交付目录这里直接复用它只补统一前缀
不再在外层重复拼 agent/运行秒数/交付目录避免内容出现两遍
"""
system_message = (update.get("system_message") or "").strip()
if not system_message:
# 兜底:极少数情况下 final_result 没有 system_message用最小信息拼一条
agent_id = update.get("agent_id")
summary = summary_override or update.get("summary") or update.get("message") or ""
deliverables_dir = update.get("deliverables_dir", "") or ""
lines = [f"子智能体{agent_id} ({summary}) 已完成任务。"]
msg = update.get("message")
if msg:
lines.append(str(msg))
if deliverables_dir:
lines.append(f"交付目录:{deliverables_dir}")
system_message = "\n\n".join(lines)
return f"[系统通知|sub_agent]\n{system_message}"
def _collect_pending_completion_notices(*, web_terminal, conversation_id: str) -> List[Dict[str, Any]]:
"""从子智能体 + 后台 run_command 两路统一取出所有待通知项(已按时间排序)。
返回的每一项形如
{
"kind": "sub_agent" | "background_command",
"message": <user 通知文本>,
"payload": <前端事件 extra payload>,
"sort_key": <用于跨两路排序的时间戳>,
}
取出时即就地标记 notified确保不会被下一轮重复消费
"""
notices: List[Dict[str, Any]] = []
# 1) 子智能体
sub_manager = getattr(web_terminal, "sub_agent_manager", None)
if sub_manager:
if not hasattr(web_terminal, "_announced_sub_agent_tasks"):
web_terminal._announced_sub_agent_tasks = set()
# 关键:先 reconcile 刷新真实状态,然后**直接遍历 tasks** 找终态+未通知的任务。
# 不依赖 poll_updates() —— 它只返回「本次调用里恰好 running→terminal」的跃迁项
# 任务一旦被提前 reconcile 成 completed跃迁就丢了poll_updates 永远返回空。
try:
sub_manager.reconcile_task_states(conversation_id=conversation_id)
except Exception:
pass
candidates = [
t for t in sub_manager.tasks.values()
if isinstance(t, dict)
and t.get("conversation_id") == conversation_id
and t.get("status") in TERMINAL_STATUSES # completed/failed/timeout不含 terminated
and not t.get("notified")
and t.get("task_id") not in web_terminal._announced_sub_agent_tasks
]
candidates.sort(key=lambda t: t.get("updated_at") or t.get("created_at") or 0)
for task_info in candidates:
task_id = task_info.get("task_id")
final_result = task_info.get("final_result")
if not isinstance(final_result, dict):
# 兜底:状态已是终态但 final_result 缺失,主动取一次
try:
final_result = sub_manager._check_task_status(task_info)
except Exception:
pass
if not isinstance(final_result, dict):
final_result = {}
# 就地标记已通知
if task_id:
web_terminal._announced_sub_agent_tasks.add(task_id)
task_info["notified"] = True
task_info["updated_at"] = time.time()
agent_id = task_info.get("agent_id")
summary = task_info.get("summary") or ""
notice_text = _build_sub_agent_notice_text(
final_result,
summary_override=summary,
)
notices.append({
"kind": "sub_agent",
"message": notice_text,
"payload": {
"sub_agent_notice": True,
"message_source": "sub_agent",
"agent_id": agent_id,
"task_id": task_id,
},
"sort_key": task_info.get("updated_at") or task_info.get("created_at") or time.time(),
})
try:
sub_manager._save_state()
except Exception:
pass
# 2) 后台 run_command
bg_manager = getattr(web_terminal, "background_command_manager", None)
if bg_manager:
try:
bg_updates = bg_manager.poll_updates(conversation_id=conversation_id)
except Exception:
bg_updates = []
for update in bg_updates or []:
command_id = update.get("command_id")
command = update.get("command") or ""
output = update.get("output") or ""
return_code = update.get("return_code")
try:
bg_manager.mark_notified(str(command_id))
except Exception:
pass
# 统一 [系统通知|background_command] 前缀;正文给出命令、退出码、输出
header = "后台指令已完成。"
if command:
header += f"\n\n命令:{command}"
if return_code is not None:
header += f"\n退出码:{return_code}"
body = output if output else "[no_output]"
message_text = f"[系统通知|background_command]\n{header}\n\n输出:\n{body}"
notices.append({
"kind": "background_command",
"message": message_text,
"payload": {
# 与子智能体完成通知完全复用同一前端通道/处理逻辑
"sub_agent_notice": True,
"message_source": "background_command",
"background_command_notice": True,
},
"sort_key": update.get("updated_at") or time.time(),
})
notices.sort(key=lambda item: item.get("sort_key") or 0)
def _has_pending_completion_work(*, web_terminal, conversation_id: str) -> bool:
"""是否还有运行中或待通知的后台任务(用于决定是否继续轮询)。"""
sub_manager = getattr(web_terminal, "sub_agent_manager", None)
if sub_manager:
announced = getattr(web_terminal, "_announced_sub_agent_tasks", set())
for task in sub_manager.tasks.values():
if not isinstance(task, dict):
continue
if not task.get("run_in_background"):
continue
if task.get("conversation_id") != conversation_id:
continue
status = task.get("status")
if status not in TERMINAL_STATUSES.union({"terminated"}):
return True # 仍在运行
if status != "terminated" and (task.get("task_id") not in announced) and not task.get("notified"):
return True # 已完成但未通知
bg_manager = getattr(web_terminal, "background_command_manager", None)
if bg_manager:
try:
if bg_manager.has_pending_for_conversation(conversation_id):
return True
except Exception:
pass
return False
async def poll_completion_notifications(*, web_terminal, workspace, conversation_id, client_sid, username):
"""统一的“通知池”轮询器。
把子智能体完成通知与后台 run_command 完成通知合并到同一条链路
- 每轮把两路所有待通知项一次性取出池化按时间排序
- 这一批里前 N-1 条作为预写通知随同一个后续任务一起注入写历史 + 事件流 + socketio
不各自触发新一轮工作
- 仅最后 1 条作为后续 chat task 的触发消息启动一次工作 停止循环
- 该后续任务结束后回到 handle_task_with_sender 结尾重新 spawn 本轮询器继续消费剩余通知
这样多个后台任务同时完成时会被合并成一次而非每条都触发一轮停止-再工作
"""
from .extensions import socketio from .extensions import socketio
manager = getattr(web_terminal, "sub_agent_manager", None) sub_manager = getattr(web_terminal, "sub_agent_manager", None)
if not manager: bg_manager = getattr(web_terminal, "background_command_manager", None)
debug_log("[SubAgent] poll_sub_agent_completion: manager 不存在") if not sub_manager and not bg_manager:
return return
if not hasattr(web_terminal, "_announced_sub_agent_tasks"): if not hasattr(web_terminal, "_announced_sub_agent_tasks"):
web_terminal._announced_sub_agent_tasks = set() web_terminal._announced_sub_agent_tasks = set()
max_wait_time = 3600 # 最多等待1小时 max_wait_time = 3600 # 最多等待 1 小时
start_wait = time.time() start_wait = time.time()
debug_log(f"[SubAgent] 开始后台轮询conversation_id={conversation_id}, username={username}")
# 创建 sender 函数,用于发送 socket 事件
def sender(event_type, data): def sender(event_type, data):
try: try:
socketio.emit(event_type, data, room=f"user_{username}") socketio.emit(event_type, data, room=f"user_{username}")
debug_log(f"[SubAgent] 发送事件: {event_type}") except Exception:
except Exception as e: pass
debug_log(f"[SubAgent] 发送事件失败: {event_type}, 错误: {e}")
loop_count = 0
while (time.time() - start_wait) < max_wait_time: while (time.time() - start_wait) < max_wait_time:
debug_log(f"[SubAgent] 轮询检查...") loop_count += 1
# 检查停止标志 # 检查停止标志
client_stop_info = get_stop_flag(client_sid, username) client_stop_info = get_stop_flag(client_sid, username)
if client_stop_info: if client_stop_info:
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
if stop_requested: if stop_requested:
debug_log("[SubAgent] 用户请求停止,终止轮询")
break break
# 若主对话仍在工具循环中,暂不消费完成事件,避免抢占 system 消息插入 # 若主对话仍在工具循环中,暂不消费完成事件,避免抢占 system 消息插入
if getattr(web_terminal, "_tool_loop_active", False): if getattr(web_terminal, "_tool_loop_active", False):
debug_log("[SubAgent] 主对话工具循环中,延迟后台轮询发送 user 消息")
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
updates = manager.poll_updates() notices = _collect_pending_completion_notices(
debug_log(f"[SubAgent] poll_updates 返回 {len(updates)} 个更新")
for update in updates:
agent_id = update.get("agent_id")
summary = update.get("summary")
result_summary = update.get("result_summary") or update.get("message", "")
deliverables_dir = update.get("deliverables_dir", "")
status = update.get("status")
task_id = update.get("task_id")
task_info = manager.tasks.get(task_id) if task_id else None
task_conv_id = task_info.get("conversation_id") if isinstance(task_info, dict) else None
if task_conv_id and task_conv_id != conversation_id:
debug_log(f"[SubAgent] 跳过非当前对话任务: task={task_id} conv={task_conv_id} current={conversation_id}")
continue
if task_id and task_info is None:
debug_log(f"[SubAgent] 找不到任务详情,跳过: task={task_id}")
continue
if status == "terminated" or (isinstance(task_info, dict) and task_info.get("notified")):
debug_log(f"[SubAgent] 跳过已终止/已通知任务: task={task_id} status={status}")
continue
debug_log(f"[SubAgent] 子智能体{agent_id}完成,状态: {status}")
# 构建 user 消息(后台完成时才发送)
prefix = "这是一句系统自动发送的user消息用于通知你子智能体已经运行完成"
runtime_line = ""
elapsed_seconds = update.get("runtime_seconds")
if elapsed_seconds is None:
elapsed_seconds = update.get("elapsed_seconds")
if status == "completed" and isinstance(elapsed_seconds, (int, float)):
runtime_line = f"\n\n运行了{int(round(elapsed_seconds))}"
user_message = f"""{prefix}
子智能体{agent_id} ({summary}) 已完成任务
{result_summary}
{runtime_line}
交付目录{deliverables_dir}"""
debug_log(f"[SubAgent] 准备发送 user_message: {user_message[:100]}...")
has_remaining = False
remaining_count = 0
try:
if task_id:
web_terminal._announced_sub_agent_tasks.add(task_id)
if isinstance(task_info, dict):
task_info["notified"] = True
task_info["updated_at"] = time.time()
try:
manager._save_state()
except Exception as exc:
debug_log(f"[SubAgent] 保存通知状态失败: {exc}")
# 计算剩余子智能体状态(用于前端清理等待标记)
if not hasattr(web_terminal, "_announced_sub_agent_tasks"):
web_terminal._announced_sub_agent_tasks = set()
announced = web_terminal._announced_sub_agent_tasks
running_tasks = [
task for task in manager.tasks.values()
if isinstance(task, dict)
and task.get("status") not in TERMINAL_STATUSES.union({"terminated"})
and task.get("run_in_background")
and task.get("conversation_id") == conversation_id
]
pending_notice_tasks = [
task for task in manager.tasks.values()
if isinstance(task, dict)
and task.get("status") in TERMINAL_STATUSES.union({"terminated"})
and task.get("run_in_background")
and task.get("conversation_id") == conversation_id
and task.get("task_id") not in announced
and not task.get("notified")
]
remaining_count = len(running_tasks) + len(pending_notice_tasks)
has_remaining = remaining_count > 0
await _dispatch_completion_user_notice(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace,
sender=sender,
client_sid=client_sid,
username=username,
conversation_id=conversation_id, conversation_id=conversation_id,
user_message=user_message,
extra_payload={
'sub_agent_notice': True,
'message_source': 'sub_agent',
'has_running_sub_agents': has_remaining,
'remaining_count': remaining_count,
},
) )
except Exception as e:
debug_log(f"[SubAgent] 创建后台任务失败,回退直接执行: {e}") if notices:
await _dispatch_completion_user_notice( # 取出后剩余是否还有未完成/未通知的后台工作(决定前端是否保持等待态)
has_remaining = _has_pending_completion_work(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace,
sender=sender,
client_sid=client_sid,
username=username,
conversation_id=conversation_id, conversation_id=conversation_id,
user_message=user_message,
extra_payload={
'sub_agent_notice': True,
'message_source': 'sub_agent',
'has_running_sub_agents': has_remaining,
'remaining_count': remaining_count,
},
) )
return # 只处理第一个完成的子智能体 preceding = notices[:-1]
last_notice = notices[-1]
# 检查是否还有运行中的任务 # 前 N-1 条预写通知:随后续任务注入历史/事件流,不单独触发工作
running_tasks = [ preceding_payload: List[Dict[str, Any]] = []
task for task in manager.tasks.values() for item in preceding:
if task.get("status") not in {"completed", "failed", "timeout", "terminated"} payload = dict(item.get("payload") or {})
and task.get("run_in_background") payload["starts_work"] = False # 预写通知渲染为完成态气泡,不起独立计时器
and task.get("conversation_id") == conversation_id preceding_payload.append({
] "message": item.get("message") or "",
"payload": payload,
debug_log(f"[SubAgent] 当前还有 {len(running_tasks)} 个运行中的任务")
if not running_tasks:
debug_log("[SubAgent] 所有子智能体已完成")
# 若状态已提前被更新为终态poll_updates 返回空),补发完成提示
completed_tasks = [
task for task in manager.tasks.values()
if task.get("status") in {"completed", "failed", "timeout"}
and task.get("run_in_background")
and task.get("conversation_id") == conversation_id
and not task.get("notified")
]
if completed_tasks:
completed_tasks.sort(
key=lambda item: item.get("updated_at") or item.get("created_at") or 0,
reverse=True
)
task = completed_tasks[0]
agent_id = task.get("agent_id")
summary = task.get("summary") or ""
final_result = task.get("final_result") or {}
result_summary = (
final_result.get("message")
or final_result.get("result_summary")
or final_result.get("system_message")
or ""
)
deliverables_dir = final_result.get("deliverables_dir") or task.get("deliverables_dir") or ""
status = final_result.get("status") or task.get("status")
debug_log(f"[SubAgent] 补发完成提示: task={task.get('task_id')} status={status}")
user_message = f"""子智能体{agent_id} ({summary}) 已完成任务。
{result_summary}
交付目录{deliverables_dir}"""
try:
task_id = task.get("task_id")
if task_id:
web_terminal._announced_sub_agent_tasks.add(task_id)
if isinstance(task, dict):
task["notified"] = True
task["updated_at"] = time.time()
try:
manager._save_state()
except Exception as exc:
debug_log(f"[SubAgent] 保存通知状态失败: {exc}")
sender('user_message', {
'message': user_message,
'conversation_id': conversation_id
}) })
from .tasks import task_manager
workspace_id = getattr(workspace, "workspace_id", None) or "default" last_payload = dict(last_notice.get("payload") or {})
host_mode = bool(getattr(workspace, "username", None) == "host") last_payload.update({
session_data = { "has_running_sub_agents": has_remaining,
"username": username, "has_running_background_commands": has_remaining,
"role": getattr(web_terminal, "user_role", "user"), "remaining_count": 1 if has_remaining else 0,
"is_api_user": getattr(web_terminal, "user_role", "") == "api", })
"host_mode": host_mode,
"host_workspace_id": workspace_id if host_mode else None,
"workspace_id": workspace_id,
"run_mode": getattr(web_terminal, "run_mode", None),
"thinking_mode": getattr(web_terminal, "thinking_mode", None),
"model_key": getattr(web_terminal, "model_key", None),
}
# 标记为自动发送的user消息子智能体完成通知
session_data["auto_user_message_event"] = True
session_data["auto_user_message_payload"] = {
"sub_agent_notice": True,
"message_source": "sub_agent",
}
rec = task_manager.create_chat_task(
username,
workspace_id,
user_message,
[],
conversation_id,
model_key=session_data.get("model_key"),
thinking_mode=session_data.get("thinking_mode"),
run_mode=session_data.get("run_mode"),
session_data=session_data,
)
debug_log(f"[SubAgent] 补发通知创建后台任务: task_id={rec.task_id}")
except Exception as e:
debug_log(f"[SubAgent] 补发通知创建后台任务失败,回退直接执行: {e}")
try: try:
task_handle = asyncio.create_task(handle_task_with_sender(
terminal=web_terminal,
workspace=workspace,
message=user_message,
images=[],
sender=sender,
client_sid=client_sid,
username=username,
videos=[]
))
await task_handle
except Exception as inner_exc:
debug_log(f"[SubAgent] 补发完成提示失败: {inner_exc}")
import traceback
debug_log(f"[SubAgent] 错误堆栈: {traceback.format_exc()}")
break
await asyncio.sleep(5)
debug_log("[SubAgent] 后台轮询结束")
async def poll_background_command_completion(*, web_terminal, workspace, conversation_id: str, client_sid: str, username: str):
"""后台轮询 run_command 后台任务并在主流程结束后代发 user 通知。"""
from .extensions import socketio
manager = getattr(web_terminal, "background_command_manager", None)
if not manager:
debug_log("[BgCommand] poll_background_command_completion: manager 不存在")
return
max_wait_time = 3600
start_wait = time.time()
debug_log(f"[BgCommand] 开始后台轮询conversation_id={conversation_id}, username={username}")
def sender(event_type, data):
try:
socketio.emit(event_type, data, room=f"user_{username}")
except Exception as e:
debug_log(f"[BgCommand] 发送事件失败: {event_type}, 错误: {e}")
while (time.time() - start_wait) < max_wait_time:
client_stop_info = get_stop_flag(client_sid, username)
if client_stop_info:
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
if stop_requested:
debug_log("[BgCommand] 用户请求停止,终止轮询")
break
if getattr(web_terminal, "_tool_loop_active", False):
debug_log("[BgCmdDebug] tool_loop_active=True, 延迟 user 代发轮询")
await asyncio.sleep(1)
continue
updates = manager.poll_updates(conversation_id=conversation_id)
debug_log(f"[BgCmdDebug] background polling updates={len(updates)} conv={conversation_id}")
for update in updates:
command_id = update.get("command_id")
output = update.get("output") or ""
content = "[后台 run_command 完成]\n" + (output if output else "[no_output]")
prefix = "这是一句系统自动发送的user消息用于通知你后台run_command已经运行完成"
user_message = f"{prefix}\n\n{content}"
debug_log(f"[BgCmdDebug] preparing user notice command_id={command_id} output_len={len(output)}")
manager.mark_notified(str(command_id))
has_remaining = manager.has_pending_for_conversation(conversation_id)
await _dispatch_completion_user_notice( await _dispatch_completion_user_notice(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace, workspace=workspace,
@ -789,26 +797,23 @@ async def poll_background_command_completion(*, web_terminal, workspace, convers
client_sid=client_sid, client_sid=client_sid,
username=username, username=username,
conversation_id=conversation_id, conversation_id=conversation_id,
user_message=user_message, user_message=last_notice.get("message") or "",
extra_payload={ extra_payload=last_payload,
# 与子智能体完成通知完全复用同一前端通道/处理逻辑 preceding_notices=preceding_payload,
'sub_agent_notice': True,
'message_source': 'background_command',
'remaining_count': 1 if has_remaining else 0,
'has_running_sub_agents': has_remaining,
'background_command_notice': True,
'has_running_background_commands': has_remaining,
},
) )
debug_log(f"[BgCmdDebug] user notice dispatched command_id={command_id} has_remaining={has_remaining}") except Exception:
pass
# 本轮已派发一个后续任务,由该任务结束后重新 spawn 本轮询器继续消费剩余通知
return return
if not manager.has_pending_for_conversation(conversation_id): # 没有待通知项:若也没有运行中的后台工作,结束轮询
debug_log("[BgCmdDebug] no pending background commands, stop polling") pending = _has_pending_completion_work(web_terminal=web_terminal, conversation_id=conversation_id)
if not pending:
break break
await asyncio.sleep(5) await asyncio.sleep(5)
debug_log("[BgCommand] 后台轮询结束")
async def handle_task_with_sender( async def handle_task_with_sender(
terminal: WebTerminal, terminal: WebTerminal,
@ -1685,24 +1690,6 @@ async def handle_task_with_sender(
'tasks': [{'agent_id': t.get('agent_id'), 'summary': t.get('summary')} for t in notify_tasks] 'tasks': [{'agent_id': t.get('agent_id'), 'summary': t.get('summary')} for t in notify_tasks]
}) })
# 启动后台任务来轮询/补发子智能体完成
def run_poll():
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(poll_sub_agent_completion(
web_terminal=web_terminal,
workspace=workspace,
conversation_id=conversation_id,
client_sid=client_sid,
username=username
))
finally:
loop.close()
socketio.start_background_task(run_poll)
# 检查是否有后台 run_command 或待通知任务 # 检查是否有后台 run_command 或待通知任务
if bg_manager and conversation_id: if bg_manager and conversation_id:
try: try:
@ -1715,12 +1702,17 @@ async def handle_task_with_sender(
# 与子智能体完全复用同一 waiting 事件(前端已有稳定处理链路) # 与子智能体完全复用同一 waiting 事件(前端已有稳定处理链路)
sender('sub_agent_waiting', _build_shared_waiting_payload(waiting_items)) sender('sub_agent_waiting', _build_shared_waiting_payload(waiting_items))
def run_bg_poll(): # 统一「通知池」轮询器:子智能体 + 后台 run_command 合并为单一轮询链路,
# 每轮一次性取出两路所有待通知项,避免逐条触发「工作 → 停止 → 再工作」循环。
# 只 spawn 一个轮询器(无论是否同时存在子智能体与后台命令),从根本上消除
# 两个轮询器同时 create_chat_task 撞「单工作区互斥」的问题。
if has_running_sub_agents or has_running_background_commands:
def run_completion_poll():
import asyncio import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
loop.run_until_complete(poll_background_command_completion( loop.run_until_complete(poll_completion_notifications(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace, workspace=workspace,
conversation_id=conversation_id, conversation_id=conversation_id,
@ -1730,7 +1722,7 @@ async def handle_task_with_sender(
finally: finally:
loop.close() loop.close()
socketio.start_background_task(run_bg_poll) socketio.start_background_task(run_completion_poll)
has_running_completion_jobs = has_running_sub_agents or has_running_background_commands has_running_completion_jobs = has_running_sub_agents or has_running_background_commands
@ -1781,6 +1773,16 @@ async def handle_task_with_sender(
# 发送完成事件(如果有后台完成任务在运行,前端会保持等待状态) # 发送完成事件(如果有后台完成任务在运行,前端会保持等待状态)
if not has_running_completion_jobs: if not has_running_completion_jobs:
# 对话真正空闲:把 work_timer 持久化到后端,避免刷新后恢复成「工作中」
try:
if web_terminal and conversation_id:
finalize_conversation_work_timer(
web_terminal,
conversation_id,
finished_at=datetime.now().isoformat(),
)
except Exception:
pass
finalize_user_work_timer() finalize_user_work_timer()
finalize_run_versioning_checkpoint("completed") finalize_run_versioning_checkpoint("completed")
else: else:

View File

@ -33,7 +33,15 @@ def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str,
return {"visibility": "hidden", "starts_work": False} return {"visibility": "hidden", "starts_work": False}
if normalized in {"guidance", "notify"}: if normalized in {"guidance", "notify"}:
return {"visibility": "compact", "starts_work": False} return {"visibility": "compact", "starts_work": False}
if normalized in {"goal_review", "compression", "compression_handoff", "sub_agent", "background_command", "presend"}: # 后台完成通知 / 运行期压缩续接:属于当前这轮工作的延续,不开启新的工作头与计时器,
# 也不暂停上一轮计时器。与 task_main 的 _user_message_ui_defaults 保持一致,
# 确保运行期直接渲染与刷新后从历史加载行为相同。
if normalized in {"sub_agent", "background_command", "compression", "compression_handoff"}:
return {"visibility": "compact", "starts_work": False}
# 目标审核续命:开启新一轮工作。
if normalized == "goal_review":
return {"visibility": "compact", "starts_work": True}
if normalized == "presend":
return {"visibility": "compact", "starts_work": True} return {"visibility": "compact", "starts_work": True}
if normalized == "goal": if normalized == "goal":
# Backward-compatible fallback for old callers. Inline goal injections are # Backward-compatible fallback for old callers. Inline goal injections are

View File

@ -1207,7 +1207,6 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
'clear_ui': True 'clear_ui': True
}, room=f"user_{username}") }, room=f"user_{username}")
compress_behavior = str(result.get("compress_behavior") or "continue").strip().lower()
response_payload = { response_payload = {
"success": True, "success": True,
"in_place": True, "in_place": True,
@ -1216,35 +1215,14 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
"summary_failed": result.get("summary_failed", False), "summary_failed": result.get("summary_failed", False),
"guide_message": result.get("guide_message"), "guide_message": result.get("guide_message"),
"compress_form": result.get("compress_form"), "compress_form": result.get("compress_form"),
"compress_behavior": compress_behavior, # 手动压缩只有一种行为:生成压缩消息(引导语),不自动续接,等待用户继续发送消息才工作。
"compress_behavior": "wait",
"load_result": load_result "load_result": load_result
} }
guide_message = (result.get("guide_message") or "").strip() guide_message = (result.get("guide_message") or "").strip()
if guide_message and compress_behavior == "continue": if guide_message:
# 继续工作:创建续接任务,自动发送引导语并触发请求(同一对话)。 # 只把引导语作为 user 消息追加进历史,不触发请求。
try:
from .tasks import task_manager
workspace_id = session.get("workspace_id") or "default"
task_rec = task_manager.create_chat_task(
username,
workspace_id,
guide_message,
images=[],
conversation_id=normalized_id,
videos=[],
message_source="compression_handoff",
)
response_payload["auto_task_started"] = True
response_payload["auto_task_id"] = task_rec.task_id
response_payload["auto_task_status"] = task_rec.status
except Exception as exc:
# 任务启动失败时前端会回退到手动发送 guide_message
debug_log(f"[Compression] 自动发送引导消息失败: {exc}")
response_payload["auto_task_started"] = False
response_payload["auto_task_error"] = str(exc)
elif guide_message and compress_behavior == "wait":
# 等待用户:只把引导语作为 user 消息追加进历史,不触发请求。
try: try:
terminal.context_manager.add_conversation( terminal.context_manager.add_conversation(
role="user", role="user",
@ -1264,11 +1242,11 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
"media_refs": [], "media_refs": [],
"message_source": "compression_handoff", "message_source": "compression_handoff",
"visibility": "compact", "visibility": "compact",
"starts_work": True, "starts_work": False,
"metadata": { "metadata": {
"message_source": "compression_handoff", "message_source": "compression_handoff",
"visibility": "compact", "visibility": "compact",
"starts_work": True, "starts_work": False,
}, },
"conversation_id": normalized_id, "conversation_id": normalized_id,
}, },

View File

@ -322,8 +322,6 @@ async def run_deep_compression(
# 读取个性化压缩设置: # 读取个性化压缩设置:
# - compress_form: file生成文件引导语提示位置 / inject把历次压缩内容注入引导语 # - compress_form: file生成文件引导语提示位置 / inject把历次压缩内容注入引导语
# - compress_behavior: continue注入引导语并触发请求 / wait仅注入引导语等待用户
# compress_behavior 仅作用于手动压缩;自动深压缩永远继续工作。
try: try:
from modules.personalization_manager import load_personalization_config from modules.personalization_manager import load_personalization_config
pconfig = load_personalization_config(workspace.data_dir) or {} pconfig = load_personalization_config(workspace.data_dir) or {}
@ -332,11 +330,10 @@ async def run_deep_compression(
compress_form = str(pconfig.get("deep_compress_form") or "file").strip().lower() compress_form = str(pconfig.get("deep_compress_form") or "file").strip().lower()
if compress_form not in ("file", "inject"): if compress_form not in ("file", "inject"):
compress_form = "file" compress_form = "file"
compress_behavior = str(pconfig.get("deep_compress_behavior") or "continue").strip().lower() # compress_behavior 固定规则(无个性化开关):
if compress_behavior not in ("continue", "wait"): # - 手动压缩:只生成压缩消息(引导语),不自动续接,等待用户继续发送消息才工作。
compress_behavior = "continue" # - 自动深压缩:当前任务尚未完成才触发,压缩后必须继续工作。
if mode != "manual": compress_behavior = "wait" if mode == "manual" else "continue"
compress_behavior = "continue"
# in-place 压缩全程操作"当前对话"的内存历史:总结、标记、状态写入都依赖 # in-place 压缩全程操作"当前对话"的内存历史:总结、标记、状态写入都依赖
# cm.conversation_history / cm.conversation_metadata。若当前对话不是目标对话 # cm.conversation_history / cm.conversation_metadata。若当前对话不是目标对话

View File

@ -17,6 +17,7 @@ from flask import current_app, session
from server.auth_helpers import api_login_required, get_current_username from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources, ensure_conversation_loaded from server.context import get_user_resources, ensure_conversation_loaded
from server.chat_flow import run_chat_task_sync from server.chat_flow import run_chat_task_sync
from server.work_timer import finalize_conversation_work_timer
from server.state import stop_flags from server.state import stop_flags
from server.utils_common import debug_log, log_conn_diag from server.utils_common import debug_log, log_conn_diag
from utils.host_workspace_debug import write_host_workspace_debug from utils.host_workspace_debug import write_host_workspace_debug
@ -799,6 +800,25 @@ class TaskManager:
# 这样前端轮询能即时看到这条 user 消息,而不是刷新后才从历史中看到。 # 这样前端轮询能即时看到这条 user 消息,而不是刷新后才从历史中看到。
try: try:
if bool((rec.session_data or {}).get("auto_user_message_event")): if bool((rec.session_data or {}).get("auto_user_message_event")):
# 先回放本批「通知池」里的前置完成通知(除触发消息外的 N-1 条),
# 保证轮询客户端按时间顺序看到所有完成通知,且不各自触发新一轮工作。
preceding_notices = (rec.session_data or {}).get("preceding_user_notices") or []
if isinstance(preceding_notices, list):
for item in preceding_notices:
if not isinstance(item, dict):
continue
notice_msg = str(item.get("message") or "").strip()
if not notice_msg:
continue
notice_payload = dict(item.get("payload") or {})
notice_payload["starts_work"] = False
notice_event = {
"message": notice_msg,
"conversation_id": rec.conversation_id,
"task_id": rec.task_id,
}
notice_event.update(notice_payload)
self._append_event(rec, "user_message", notice_event)
extra_payload = (rec.session_data or {}).get("auto_user_message_payload") or {} extra_payload = (rec.session_data or {}).get("auto_user_message_payload") or {}
if not isinstance(extra_payload, dict): if not isinstance(extra_payload, dict):
extra_payload = {} extra_payload = {}
@ -959,28 +979,28 @@ class TaskManager:
) )
except Exception as exc: except Exception as exc:
debug_log(f"[TaskRun] 发送 task_stopped 失败: {exc}") debug_log(f"[TaskRun] 发送 task_stopped 失败: {exc}")
# 持久化:将最后一条用户消息的 work_timer 标记为完成,避免刷新后仍显示工作中
try:
if terminal and getattr(terminal, "context_manager", None) and rec.conversation_id:
cm = getattr(terminal.context_manager, "conversation_manager", None)
if cm:
cm.mark_latest_user_work_completed(rec.conversation_id)
debug_log(f"[TaskRun] 已持久化 work_timer 完成: task_id={rec.task_id}, conversation_id={rec.conversation_id}")
except Exception as exc:
debug_log(f"[TaskRun] 持久化 work_timer 完成失败: {exc}")
else: else:
with self._lock: with self._lock:
rec.status = "succeeded" rec.status = "succeeded"
rec.updated_at = time.time() rec.updated_at = time.time()
# 正常结束时同样持久化 work_timer保证刷新后状态一致
# 任务线程结束:仅当对话真正空闲(无其它前台/后台任务)时才把 work_timer 标记为完成,
# 避免智能体已停、但后台子智能体/后台命令/压缩仍在进行时提前停止计时。
try: try:
if terminal and getattr(terminal, "context_manager", None) and rec.conversation_id: if terminal and rec.conversation_id:
cm = getattr(terminal.context_manager, "conversation_manager", None) active_task_ids = {
if cm: t.task_id
cm.mark_latest_user_work_completed(rec.conversation_id) for t in self.list_tasks(username, rec.workspace_id)
debug_log(f"[TaskRun] 正常结束已持久化 work_timer 完成: task_id={rec.task_id}, conversation_id={rec.conversation_id}") if t.status in {"pending", "running"}
}
finalized = finalize_conversation_work_timer(
terminal,
rec.conversation_id,
exclude_task_id=rec.task_id,
active_task_ids=active_task_ids,
)
except Exception as exc: except Exception as exc:
debug_log(f"[TaskRun] 正常结束持久化 work_timer 完成失败: {exc}") pass
except Exception as exc: except Exception as exc:
debug_log(f"[Task] 后台任务失败: {exc}") debug_log(f"[Task] 后台任务失败: {exc}")
self._append_event(rec, "error", {"message": str(exc)}) self._append_event(rec, "error", {"message": str(exc)})

95
server/work_timer.py Normal file
View File

@ -0,0 +1,95 @@
"""对话工作计时器持久化与空闲判定辅助函数。
对话真正停止运行没有其它前台任务也没有后台子智能体/后台命令/压缩
这一时刻的 work_timer 同步到磁盘并刷新当前 ContextManager 内存中的副本
避免刷新页面后计时器又恢复成工作中
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional, Set
from modules.sub_agent import TERMINAL_STATUSES as SUB_AGENT_TERMINAL_STATUSES
def has_running_background_work(terminal: Any, conversation_id: Optional[str]) -> bool:
"""检测指定对话是否还有未完成的后台工作(子智能体、后台命令、待通知完成项)。"""
if not terminal or not conversation_id:
return False
sub_agent_manager = getattr(terminal, "sub_agent_manager", None)
if sub_agent_manager:
try:
sub_agent_manager.reconcile_task_states(conversation_id=conversation_id)
for task_info in sub_agent_manager.tasks.values():
if task_info.get("conversation_id") != conversation_id:
continue
status = task_info.get("status")
if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}):
return True
# 已完成但尚未发出通知,对话仍应视为运行中
if status in SUB_AGENT_TERMINAL_STATUSES and not task_info.get("notified"):
return True
except Exception:
pass
bg_manager = getattr(terminal, "background_command_manager", None)
if bg_manager:
try:
bg_manager.reconcile_stale_records(conversation_id=conversation_id)
if bg_manager.has_pending_for_conversation(conversation_id):
return True
except Exception:
pass
return False
def finalize_conversation_work_timer(
terminal: Any,
conversation_id: Optional[str],
*,
finished_at: Optional[str] = None,
exclude_task_id: Optional[str] = None,
active_task_ids: Optional[Set[str]] = None,
) -> bool:
"""当对话真正停止运行时,把 work_timer 持久化到后端并同步内存。
参数
terminal: 当前终端实例需携带 context_manager
conversation_id: 要处理的对话 ID
finished_at: 完成时间 ISO 字符串默认当前时间
exclude_task_id: 在判断是否还有其它前台任务时要排除的任务 ID
active_task_ids: 当前仍活跃的前台任务 ID 集合若提供且除 exclude_task_id
外仍有其它任务则视为对话仍在运行不做持久化
"""
if not terminal or not conversation_id:
return False
# 1. 空闲判定:还有其它前台任务在跑?
if active_task_ids:
others = {tid for tid in active_task_ids if tid and tid != exclude_task_id}
if others:
return False
# 2. 空闲判定:还有后台工作?
if has_running_background_work(terminal, conversation_id):
return False
context_manager = getattr(terminal, "context_manager", None)
if not context_manager:
return False
cm = getattr(context_manager, "conversation_manager", None)
if not cm:
return False
now_iso = finished_at or datetime.now().isoformat()
# 3. 持久化到磁盘并同步内存副本
try:
return cm.mark_latest_user_work_completed(
conversation_id, now_iso, context_manager=context_manager
)
except Exception:
return False

View File

@ -76,34 +76,14 @@ export const chatMethods = {
await this.loadConversation(newId, { force: true }); await this.loadConversation(newId, { force: true });
} }
const guideMessage = (result.guide_message || '').trim(); const guideMessage = (result.guide_message || '').trim();
const autoTaskStarted = !!result.auto_task_started; // 手动压缩只有一种行为:后端已把引导语作为 user 消息追加进历史,
const autoTaskId = result.auto_task_id; // 这里刷新展示即可,不触发新一轮请求(等待用户继续发送消息才工作)。
const compressBehavior = String(result.compress_behavior || 'continue');
if (autoTaskStarted && autoTaskId) {
const { useTaskStore } = await import('../../../stores/task');
const taskStore = useTaskStore();
if (typeof this.clearProcessedEvents === 'function') {
this.clearProcessedEvents();
}
taskStore.resumeTask(autoTaskId, {
status: result.auto_task_status || 'running',
resetOffset: true,
eventHandler: (event) => this.handleTaskEvent(event)
});
this.taskInProgress = true;
if (typeof this.monitorShowPendingReply === 'function') {
this.monitorShowPendingReply();
}
} else if (compressBehavior === 'wait') {
// 等待用户:后端已把引导语作为 user 消息追加进历史,这里刷新展示,不触发请求。
if (newId && !isInPlace) { if (newId && !isInPlace) {
await this.loadConversation(newId, { force: true }); await this.loadConversation(newId, { force: true });
} else if (isInPlace) { } else if (isInPlace) {
await this.loadConversation(this.currentConversationId, { force: true }); await this.loadConversation(this.currentConversationId, { force: true });
} }
} else if (guideMessage) { void guideMessage;
await this.sendAutoUserMessage(guideMessage);
}
if (!isInPlace) { if (!isInPlace) {
await this.loadConversationsList(); await this.loadConversationsList();

View File

@ -471,11 +471,9 @@ export const lifecycleMethods = {
this.streamingMessage = false; this.streamingMessage = false;
this.stopRequested = false; this.stopRequested = false;
// 智能体工作本身已结束,无论是否还有后台任务,先把 work_timer 标记为完成
this.markLatestUserWorkCompleted();
if (hasRunningBackground) { if (hasRunningBackground) {
// 智能体已停,但后台任务还在跑:保持停止按钮,等用户第二下清理 // 智能体已停,但后台任务还在跑:保持停止按钮,等用户第二下清理
// 此时对话并未真正停止work_timer 保持运行,不提前标记完成
this.taskInProgress = true; this.taskInProgress = true;
this.waitingForSubAgent = hasRunningSubAgents; this.waitingForSubAgent = hasRunningSubAgents;
this.waitingForBackgroundCommand = hasRunningBackgroundCommands; this.waitingForBackgroundCommand = hasRunningBackgroundCommands;
@ -491,6 +489,8 @@ export const lifecycleMethods = {
console.warn('[TaskPolling] uiPushToast 调用失败:', err); console.warn('[TaskPolling] uiPushToast 调用失败:', err);
} }
} else { } else {
// 对话真正停止:停止计时器并持久化
this.markLatestUserWorkCompleted();
this.taskInProgress = false; this.taskInProgress = false;
this.waitingForSubAgent = false; this.waitingForSubAgent = false;
this.waitingForBackgroundCommand = false; this.waitingForBackgroundCommand = false;

View File

@ -194,6 +194,15 @@ function renderEnhancedToolResult(): string {
return renderEasterEgg(result, args); return renderEasterEgg(result, args);
} }
//
else if (name === 'create_sub_agent') {
return renderCreateSubAgent(result, args);
} else if (name === 'terminate_sub_agent') {
return renderTerminateSubAgent(result, args);
} else if (name === 'get_sub_agent_status') {
return renderGetSubAgentStatus(result, args);
}
// JSON // JSON
return `<pre>${escapeHtml(JSON.stringify(result || args, null, 2))}</pre>`; return `<pre>${escapeHtml(JSON.stringify(result || args, null, 2))}</pre>`;
} }
@ -1080,6 +1089,125 @@ function renderEasterEgg(result: any, args: any): string {
return html; return html;
} }
// ===== =====
function renderCreateSubAgent(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已创建', '✗ 创建失败');
const agentId = result.agent_id ?? args.agent_id ?? '';
const taskId = result.task_id ?? '';
const deliverablesDir = result.deliverables_dir ?? '';
const taskDescription = args.task ?? '';
const message = result.message ?? result.summary ?? '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (agentId !== '') {
html += `<div><strong>子智能体 ID</strong>${escapeHtml(String(agentId))}</div>`;
}
if (taskId !== '') {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
if (deliverablesDir !== '') {
html += `<div><strong>交付目录:</strong>${escapeHtml(String(deliverablesDir))}</div>`;
}
if (taskDescription) {
const preview = String(taskDescription).slice(0, 200);
const suffix = String(taskDescription).length > 200 ? '…' : '';
html += `<div><strong>任务:</strong>${escapeHtml(preview + suffix)}</div>`;
}
html += '</div>';
if (message) {
html += '<div class="tool-result-content">';
html += `<div>${escapeHtml(String(message))}</div>`;
html += '</div>';
}
return html;
}
function renderTerminateSubAgent(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已关闭', '✗ 关闭失败');
const agentId = result.agent_id ?? args.agent_id ?? '';
const taskId = result.task_id ?? '';
const message = result.message ?? result.system_message ?? '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (agentId !== '') {
html += `<div><strong>子智能体 ID</strong>${escapeHtml(String(agentId))}</div>`;
}
if (taskId !== '') {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
html += '</div>';
if (message) {
html += '<div class="tool-result-content">';
html += `<div>${escapeHtml(String(message))}</div>`;
html += '</div>';
}
return html;
}
function renderGetSubAgentStatus(result: any, args: any): string {
if (!result.success) {
const error = result.error ?? '查询失败';
return `<div class="tool-result-error">⚠️ ${escapeHtml(String(error))}</div>`;
}
const results = Array.isArray(result.results) ? result.results : [];
if (results.length === 0) {
return '<div class="tool-result-empty">未返回子智能体状态。</div>';
}
let html = '<div class="sub-agent-status-list">';
results.forEach((item: any) => {
const agentId = item.agent_id ?? '';
const found = item.found !== false;
const status = String(item.status || '');
const taskId = item.task_id ?? '';
const summary = item.summary ?? (item.final_result?.message) ?? (item.final_result?.summary) ?? '';
html += '<div class="sub-agent-status-item">';
html += `<div class="sub-agent-status-header">子智能体 #${escapeHtml(String(agentId))}</div>`;
if (!found) {
html += '<div class="tool-result-meta">';
html += '<div><strong>状态:</strong>不存在</div>';
html += '</div>';
} else {
html += '<div class="tool-result-meta">';
if (status === 'completed') {
html += '<div><strong>状态:</strong>已完成</div>';
} else if (status === 'terminated') {
html += '<div><strong>状态:</strong>已终止</div>';
} else if (status === 'running') {
html += '<div><strong>状态:</strong>运行中</div>';
} else if (status === 'pending') {
html += '<div><strong>状态:</strong>等待中</div>';
} else {
html += `<div><strong>状态:</strong>${escapeHtml(status || '未知')}</div>`;
}
if (taskId !== '') {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
html += '</div>';
if (summary) {
html += '<div class="tool-result-content">';
html += `<div>${escapeHtml(String(summary))}</div>`;
html += '</div>';
}
}
html += '</div>';
});
html += '</div>';
return html;
}
</script> </script>
<style> <style>

View File

@ -131,6 +131,15 @@ export function renderEnhancedToolResult(
return renderEasterEgg(result, args); return renderEasterEgg(result, args);
} }
// 子智能体类
else if (name === 'create_sub_agent') {
return renderCreateSubAgent(result, args);
} else if (name === 'terminate_sub_agent') {
return renderTerminateSubAgent(result, args);
} else if (name === 'get_sub_agent_status') {
return renderGetSubAgentStatus(result, args);
}
return ''; return '';
} }
@ -1048,3 +1057,122 @@ function renderEasterEgg(result: any, args: any): string {
return html; return html;
} }
// 子智能体类渲染函数
function renderCreateSubAgent(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已创建', '✗ 创建失败');
const agentId = result.agent_id ?? args.agent_id ?? '';
const taskId = result.task_id ?? '';
const deliverablesDir = result.deliverables_dir ?? '';
const taskDescription = args.task ?? '';
const message = result.message ?? result.summary ?? '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (agentId !== '') {
html += `<div><strong>子智能体 ID</strong>${escapeHtml(String(agentId))}</div>`;
}
if (taskId !== '') {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
if (deliverablesDir !== '') {
html += `<div><strong>交付目录:</strong>${escapeHtml(String(deliverablesDir))}</div>`;
}
if (taskDescription) {
const preview = String(taskDescription).slice(0, 200);
const suffix = String(taskDescription).length > 200 ? '…' : '';
html += `<div><strong>任务:</strong>${escapeHtml(preview + suffix)}</div>`;
}
html += '</div>';
if (message) {
html += '<div class="tool-result-content">';
html += `<div>${escapeHtml(String(message))}</div>`;
html += '</div>';
}
return html;
}
function renderTerminateSubAgent(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已关闭', '✗ 关闭失败');
const agentId = result.agent_id ?? args.agent_id ?? '';
const taskId = result.task_id ?? '';
const message = result.message ?? result.system_message ?? '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
if (agentId !== '') {
html += `<div><strong>子智能体 ID</strong>${escapeHtml(String(agentId))}</div>`;
}
if (taskId !== '') {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
html += '</div>';
if (message) {
html += '<div class="tool-result-content">';
html += `<div>${escapeHtml(String(message))}</div>`;
html += '</div>';
}
return html;
}
function renderGetSubAgentStatus(result: any, args: any): string {
if (!result.success) {
const error = result.error ?? '查询失败';
return `<div class="tool-result-error">⚠️ ${escapeHtml(String(error))}</div>`;
}
const results = Array.isArray(result.results) ? result.results : [];
if (results.length === 0) {
return '<div class="tool-result-empty">未返回子智能体状态。</div>';
}
let html = '<div class="sub-agent-status-list">';
results.forEach((item: any) => {
const agentId = item.agent_id ?? '';
const found = item.found !== false;
const status = String(item.status || '');
const taskId = item.task_id ?? '';
const summary = item.summary ?? (item.final_result?.message) ?? (item.final_result?.summary) ?? '';
html += '<div class="sub-agent-status-item">';
html += `<div class="sub-agent-status-header">子智能体 #${escapeHtml(String(agentId))}</div>`;
if (!found) {
html += '<div class="tool-result-meta">';
html += '<div><strong>状态:</strong>不存在</div>';
html += '</div>';
} else {
html += '<div class="tool-result-meta">';
if (status === 'completed') {
html += '<div><strong>状态:</strong>已完成</div>';
} else if (status === 'terminated') {
html += '<div><strong>状态:</strong>已终止</div>';
} else if (status === 'running') {
html += '<div><strong>状态:</strong>运行中</div>';
} else if (status === 'pending') {
html += '<div><strong>状态:</strong>等待中</div>';
} else {
html += `<div><strong>状态:</strong>${escapeHtml(status || '未知')}</div>`;
}
if (taskId !== '') {
html += `<div><strong>任务 ID</strong>${escapeHtml(String(taskId))}</div>`;
}
html += '</div>';
if (summary) {
html += '<div class="tool-result-content">';
html += `<div>${escapeHtml(String(summary))}</div>`;
html += '</div>';
}
}
html += '</div>';
});
html += '</div>';
return html;
}

View File

@ -1200,28 +1200,6 @@
class="fancy-path" class="fancy-path"
></path></svg></span ></path></svg></span
></label> ></label>
<label class="settings-toggle-row inner"
><span class="settings-row-copy"
><span class="settings-row-title">手动压缩后等待用户</span
><span class="settings-row-desc"
>开启后手动压缩只插入引导语并等待输入关闭则自动继续工作</span
></span
><input
type="checkbox"
:checked="form.deep_compress_behavior === 'wait'"
@change="
personalization.updateField({
key: 'deep_compress_behavior',
value: $event.target.checked ? 'wait' : 'continue'
})
" /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<div class="settings-inline-actions right"> <div class="settings-inline-actions right">
<button <button
type="button" type="button"

View File

@ -52,7 +52,6 @@ interface PersonalForm {
shallow_compress_trigger_tool_calls_interval: number | null; shallow_compress_trigger_tool_calls_interval: number | null;
deep_compress_trigger_tokens: number | null; deep_compress_trigger_tokens: number | null;
deep_compress_form: 'file' | 'inject'; deep_compress_form: 'file' | 'inject';
deep_compress_behavior: 'continue' | 'wait';
agents_md_auto_inject: boolean; agents_md_auto_inject: boolean;
allow_root_file_creation: boolean; allow_root_file_creation: boolean;
default_hide_workspace: boolean; default_hide_workspace: boolean;
@ -151,7 +150,6 @@ const defaultForm = (): PersonalForm => ({
shallow_compress_trigger_tool_calls_interval: null, shallow_compress_trigger_tool_calls_interval: null,
deep_compress_trigger_tokens: null, deep_compress_trigger_tokens: null,
deep_compress_form: 'file', deep_compress_form: 'file',
deep_compress_behavior: 'continue',
agents_md_auto_inject: false, agents_md_auto_inject: false,
allow_root_file_creation: false, allow_root_file_creation: false,
default_hide_workspace: false, default_hide_workspace: false,
@ -373,7 +371,6 @@ export const usePersonalizationStore = defineStore('personalization', {
data.deep_compress_trigger_tokens data.deep_compress_trigger_tokens
), ),
deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file', deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file',
deep_compress_behavior: data.deep_compress_behavior === 'wait' ? 'wait' : 'continue',
agents_md_auto_inject: !!data.agents_md_auto_inject, agents_md_auto_inject: !!data.agents_md_auto_inject,
allow_root_file_creation: !!data.allow_root_file_creation, allow_root_file_creation: !!data.allow_root_file_creation,
default_hide_workspace: !!data.default_hide_workspace, default_hide_workspace: !!data.default_hide_workspace,

View File

@ -1,7 +1,9 @@
export type MessageVisibility = 'chat' | 'compact' | 'hidden'; export type MessageVisibility = 'chat' | 'compact' | 'hidden';
const VALID_VISIBILITY = new Set(['chat', 'compact', 'hidden']); const VALID_VISIBILITY = new Set(['chat', 'compact', 'hidden']);
const LEGACY_STARTS_WORK_SOURCES = new Set(['user', 'presend', 'sub_agent', 'background_command']); // 仅这些来源在缺少显式 starts_work 元数据时,回退判定为「开启新一轮工作」。
// 后台完成通知sub_agent/background_command属于当前工作的延续不在此列。
const LEGACY_STARTS_WORK_SOURCES = new Set(['user', 'presend']);
const COMPACT_FALLBACK_SOURCES = new Set([ const COMPACT_FALLBACK_SOURCES = new Set([
'guidance', 'guidance',
'goal_review', 'goal_review',

View File

@ -346,10 +346,22 @@ class CrudMixin:
print(f"⌘ 保存对话失败 {conversation_id}: {e}") print(f"⌘ 保存对话失败 {conversation_id}: {e}")
return False return False
def mark_latest_user_work_completed(self, conversation_id: str, finished_at: Optional[str] = None) -> bool: def mark_latest_user_work_completed(
self,
conversation_id: str,
finished_at: Optional[str] = None,
context_manager: Optional[Any] = None,
) -> bool:
"""将对话中最后一条 status 为 working 的用户消息的 work_timer 标记为完成。 """将对话中最后一条 status 为 working 的用户消息的 work_timer 标记为完成。
用于任务停止或正常结束后刷新页面时不再恢复为"工作中"状态 用于任务停止或正常结束后刷新页面时不再恢复为"工作中"状态
Args:
conversation_id: 要处理的对话 ID
finished_at: 完成时间 ISO 字符串默认当前时间
context_manager: 可选的当前 ContextManager 实例若提供会同步更新
其内存中的 conversation_history避免后续 auto_save 把已持久化的
完成状态覆盖回去
""" """
if not conversation_id: if not conversation_id:
return False return False
@ -388,11 +400,56 @@ class CrudMixin:
data["updated_at"] = datetime.now().isoformat() data["updated_at"] = datetime.now().isoformat()
self._save_conversation_file(conversation_id, data) self._save_conversation_file(conversation_id, data)
self._update_index(conversation_id, data) self._update_index(conversation_id, data)
# 同步内存副本(如果调用方持有当前对话的内存引用)
if (
context_manager
and getattr(context_manager, "current_conversation_id", None) == conversation_id
):
try:
self._sync_work_timer_in_memory(context_manager, now_iso)
except Exception:
pass
return True return True
except Exception as exc: except Exception as exc:
print(f"⌘ 标记用户工作完成失败 {conversation_id}: {exc}") print(f"⌘ 标记用户工作完成失败 {conversation_id}: {exc}")
return False return False
def _sync_work_timer_in_memory(self, context_manager: Any, finished_at: str) -> bool:
"""将当前 ContextManager 内存中最后一条 working 的 work_timer 标记为完成。"""
history = getattr(context_manager, "conversation_history", None) or []
if not history:
return False
for msg in reversed(history):
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
metadata = msg.get("metadata") or {}
timer = metadata.get("work_timer")
if not isinstance(timer, dict):
continue
if timer.get("status") != "working":
continue
started_at = timer.get("started_at") or msg.get("timestamp") or finished_at
try:
start_dt = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(finished_at.replace("Z", "+00:00"))
duration_ms = max(0, int((end_dt - start_dt).total_seconds() * 1000))
except Exception:
duration_ms = timer.get("duration_ms", 0) or 0
timer.update({
"status": "completed",
"started_at": started_at,
"finished_at": finished_at,
"duration_ms": duration_ms,
})
msg["metadata"] = metadata
return True
return False
def update_project_snapshot( def update_project_snapshot(
self, self,
conversation_id: str, conversation_id: str,

View File

@ -205,7 +205,7 @@ def _format_get_sub_agent_status(result_data: Dict[str, Any]) -> str:
for item in results: for item in results:
agent_id = item.get("agent_id") agent_id = item.get("agent_id")
if not item.get("found"): if not item.get("found"):
blocks.append(f"子智能体 #{agent_id} 未找到") blocks.append(f"子智能体 #{agent_id} 不存在")
continue continue
status = item.get("status") status = item.get("status")
summary = None summary = None
@ -219,6 +219,14 @@ def _format_get_sub_agent_status(result_data: Dict[str, Any]) -> str:
if not summary: if not summary:
summary = item.get("summary") or "" summary = item.get("summary") or ""
stats_text = _format_sub_agent_stats(item.get("stats")) stats_text = _format_sub_agent_stats(item.get("stats"))
if status == "completed":
lines = [f"子智能体 #{agent_id} 已完成"]
elif status == "terminated":
lines = [f"子智能体 #{agent_id} 已终止"]
elif status in {"failed", "timeout"}:
lines = [f"⚠️ 子智能体 #{agent_id} 状态 {status}"]
else:
lines = [f"子智能体 #{agent_id} 状态: {status}"] lines = [f"子智能体 #{agent_id} 状态: {status}"]
if stats_text: if stats_text:
lines.append(stats_text) lines.append(stats_text)