Introduce workspace-level goal state persistence, goal prompt injection, and after-turn review handling so an active task can continue until the configured completion conditions are met. Add a dedicated goal review agent with readonly and active evidence modes, configurable model settings, review prompt, token/turn boundaries, idle-no-tool protection, and progress/completed/stopped events. Wire goal_mode through task creation, task restoration, compression handoff, runtime user messages, API message sanitization, and tool-call ordering so goal continuations survive long-running tasks and deep compression. Add Vue UI for arming goal mode from the quick menu, showing running/completed banners, displaying progress metrics, restoring running goal state, and exposing personalization settings for review mode and stop limits. Include goal mode research notes and default goal review configuration.
321 lines
13 KiB
Python
321 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import time
|
||
from typing import Any, Callable, Dict, List, Optional
|
||
|
||
from modules.sub_agent_manager import TERMINAL_STATUSES
|
||
|
||
|
||
_VALID_SOURCES = {
|
||
"guidance",
|
||
"notify",
|
||
"user",
|
||
"presend",
|
||
"sub_agent",
|
||
"background_command",
|
||
"goal",
|
||
}
|
||
|
||
|
||
def inject_runtime_user_message(
|
||
*,
|
||
web_terminal,
|
||
messages: Optional[List[Dict]],
|
||
text: str,
|
||
source: str,
|
||
sender: Optional[Callable[[str, Dict[str, Any]], None]] = None,
|
||
conversation_id: Optional[str] = None,
|
||
after_tool_call_id: Optional[str] = None,
|
||
inline: bool,
|
||
extra_metadata: Optional[Dict[str, Any]] = None,
|
||
persist: bool = True,
|
||
) -> Optional[str]:
|
||
"""运行期/空闲期统一向对话插入一条 user 消息(带 [系统通知|{source}] 前缀)。
|
||
|
||
- inline=True 表示运行期插入(仅作为通知,不主动触发新一轮回复)。
|
||
- inline=False 表示空闲期插入(等价于一条用户消息,由调用方决定是否启动新一轮回复)。
|
||
- messages=None 时仅持久化到对话历史并广播前端(无运行中的 messages 列表可写)。
|
||
"""
|
||
raw = "" if text is None else str(text).strip()
|
||
if not raw:
|
||
return None
|
||
|
||
src = str(source or "").strip().lower()
|
||
if src not in _VALID_SOURCES:
|
||
src = "guidance"
|
||
|
||
formatted = f"[系统通知|{src}]\n{raw}"
|
||
|
||
metadata: Dict[str, Any] = {
|
||
"runtime_injected": True,
|
||
"source": src,
|
||
"inline": inline,
|
||
"is_auto_generated": True,
|
||
"auto_message_type": f"runtime_{src}",
|
||
"message_source": src,
|
||
"runtime_guidance": True,
|
||
"runtime_guidance_original": raw,
|
||
}
|
||
if extra_metadata:
|
||
metadata.update({k: v for k, v in extra_metadata.items() if v is not None})
|
||
|
||
if persist:
|
||
try:
|
||
ctx_manager = getattr(web_terminal, "context_manager", None)
|
||
if ctx_manager is not None:
|
||
ctx_manager.add_conversation("user", formatted, metadata=metadata)
|
||
except Exception:
|
||
pass
|
||
|
||
if messages is not None:
|
||
insert_index = len(messages)
|
||
if after_tool_call_id:
|
||
for idx, msg in enumerate(messages):
|
||
if msg.get("role") == "tool" and msg.get("tool_call_id") == after_tool_call_id:
|
||
# 跳过紧随其后的所有 tool 消息,避免把 user 插进同一 assistant.tool_calls 的 tool 序列中间,
|
||
# 否则会触发 "insufficient tool messages following tool_calls" 错误。
|
||
end = idx + 1
|
||
while end < len(messages) and messages[end].get("role") == "tool":
|
||
end += 1
|
||
insert_index = end
|
||
break
|
||
messages.insert(insert_index, {
|
||
"role": "user",
|
||
"content": formatted,
|
||
})
|
||
|
||
if callable(sender):
|
||
payload: Dict[str, Any] = {
|
||
"message": formatted,
|
||
"content": formatted,
|
||
"conversation_id": conversation_id,
|
||
"inline": inline,
|
||
"source": src,
|
||
"message_source": src,
|
||
"runtime_guidance": True,
|
||
"runtime_guidance_original": raw,
|
||
}
|
||
for key in ("task_id", "command_id"):
|
||
value = (extra_metadata or {}).get(key)
|
||
if value is not None:
|
||
payload[key] = value
|
||
try:
|
||
sender("user_message", payload)
|
||
except Exception:
|
||
pass
|
||
|
||
return formatted
|
||
|
||
|
||
async def process_sub_agent_updates(*, messages: List[Dict], inline: bool = False, after_tool_call_id: Optional[str] = None, web_terminal, sender, debug_log, maybe_mark_failure_from_message):
|
||
"""轮询子智能体任务并通知前端,并把结果插入当前对话上下文。"""
|
||
manager = getattr(web_terminal, "sub_agent_manager", None)
|
||
if not manager:
|
||
return
|
||
|
||
# 获取已通知的任务集合
|
||
if not hasattr(web_terminal, '_announced_sub_agent_tasks'):
|
||
web_terminal._announced_sub_agent_tasks = set()
|
||
|
||
try:
|
||
updates = manager.poll_updates()
|
||
debug_log(f"[SubAgent] poll inline={inline} after_tool_call_id={after_tool_call_id} updates={len(updates)}")
|
||
except Exception as exc:
|
||
debug_log(f"子智能体状态检查失败: {exc}")
|
||
return
|
||
|
||
# 兜底:如果 poll_updates 没命中,但任务已被别处更新为终态且未通知,补发一次
|
||
if not updates:
|
||
synthesized = []
|
||
try:
|
||
for task_id, task in getattr(manager, "tasks", {}).items():
|
||
if not isinstance(task, dict):
|
||
continue
|
||
status = task.get("status")
|
||
if status not in TERMINAL_STATUSES.union({"terminated"}):
|
||
continue
|
||
if task.get("notified"):
|
||
continue
|
||
task_conv_id = task.get("conversation_id")
|
||
current_conv_id = getattr(getattr(web_terminal, "context_manager", None), "current_conversation_id", None)
|
||
if task_conv_id and current_conv_id and task_conv_id != current_conv_id:
|
||
continue
|
||
final_result = task.get("final_result")
|
||
if not final_result:
|
||
try:
|
||
final_result = manager._check_task_status(task)
|
||
except Exception:
|
||
final_result = None
|
||
if isinstance(final_result, dict):
|
||
synthesized.append(final_result)
|
||
except Exception as exc:
|
||
debug_log(f"[SubAgent] synthesized updates failed: {exc}")
|
||
synthesized = []
|
||
|
||
if synthesized:
|
||
updates = synthesized
|
||
debug_log(f"[SubAgent] synthesized updates count={len(updates)}")
|
||
|
||
if inline and not hasattr(web_terminal, "_inline_sub_agent_notified"):
|
||
web_terminal._inline_sub_agent_notified = set()
|
||
|
||
for update in updates:
|
||
task_id = update.get("task_id")
|
||
task_info = manager.tasks.get(task_id) if task_id else None
|
||
current_conv_id = getattr(getattr(web_terminal, "context_manager", None), "current_conversation_id", None)
|
||
task_conv_id = task_info.get("conversation_id") if isinstance(task_info, dict) else None
|
||
if task_conv_id and current_conv_id and task_conv_id != current_conv_id:
|
||
debug_log(f"[SubAgent] 跳过非当前对话任务: task={task_id} conv={task_conv_id} current={current_conv_id}")
|
||
continue
|
||
if task_id and task_info is None:
|
||
debug_log(f"[SubAgent] 找不到任务详情,跳过: task={task_id}")
|
||
continue
|
||
|
||
# 检查是否已经通知过这个任务
|
||
if task_id and task_id in web_terminal._announced_sub_agent_tasks:
|
||
debug_log(f"[SubAgent] 任务 {task_id} 已通知过,跳过")
|
||
continue
|
||
if isinstance(task_info, dict) and task_info.get("notified"):
|
||
debug_log(f"[SubAgent] 任务 {task_id} notified=true,跳过")
|
||
continue
|
||
|
||
message = update.get("system_message")
|
||
if not message:
|
||
debug_log(f"[SubAgent] update missing system_message: task={task_id} keys={list(update.keys())}")
|
||
continue
|
||
|
||
if inline:
|
||
inline_key = ("task", task_id) if task_id else ("msg", message)
|
||
if inline_key in web_terminal._inline_sub_agent_notified:
|
||
debug_log(f"[SubAgent] inline 通知已发送,跳过: key={inline_key}")
|
||
continue
|
||
|
||
debug_log(f"[SubAgent] update task={task_id} inline={inline} msg={message}")
|
||
|
||
# 标记任务已通知
|
||
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}")
|
||
|
||
conversation_id = getattr(getattr(web_terminal, "context_manager", None), "current_conversation_id", None)
|
||
inject_runtime_user_message(
|
||
web_terminal=web_terminal,
|
||
messages=messages,
|
||
text=message,
|
||
source="sub_agent",
|
||
sender=sender,
|
||
conversation_id=conversation_id,
|
||
after_tool_call_id=after_tool_call_id,
|
||
inline=inline,
|
||
extra_metadata={"task_id": task_id},
|
||
)
|
||
if inline:
|
||
web_terminal._inline_sub_agent_notified.add(inline_key)
|
||
debug_log(f"[SubAgent] 插入子智能体通知 after_tool_call_id={after_tool_call_id} inline={inline}")
|
||
maybe_mark_failure_from_message(web_terminal, message)
|
||
|
||
|
||
async def process_background_command_updates(*, messages: List[Dict], inline: bool = False, after_tool_call_id: Optional[str] = None, web_terminal, sender, debug_log, maybe_mark_failure_from_message):
|
||
"""轮询后台 run_command 完成状态并发送 system 消息通知。"""
|
||
manager = getattr(web_terminal, "background_command_manager", None)
|
||
if not manager:
|
||
debug_log("[BgCmdDebug] process_background_command_updates skipped: manager 不存在")
|
||
return
|
||
|
||
conversation_id = getattr(getattr(web_terminal, "context_manager", None), "current_conversation_id", None)
|
||
debug_log(f"[BgCmdDebug] process_background_command_updates start inline={inline} after_tool_call_id={after_tool_call_id} conv={conversation_id}")
|
||
try:
|
||
updates = manager.poll_updates(conversation_id=conversation_id)
|
||
debug_log(f"[BgCmdDebug] poll_updates returned {len(updates)} updates")
|
||
except Exception as exc:
|
||
debug_log(f"[BgCommand] poll 更新失败: {exc}")
|
||
return
|
||
|
||
if not updates:
|
||
debug_log("[BgCmdDebug] no background command updates")
|
||
return
|
||
|
||
for update in updates:
|
||
command_id = update.get("command_id")
|
||
output = update.get("output") or ""
|
||
message = "[后台 run_command 完成]\n" + (output if output else "[no_output]")
|
||
status = update.get("status")
|
||
debug_log(
|
||
f"[BgCmdDebug] emit inline notice command_id={command_id} status={status} "
|
||
f"output_len={len(output)} inline={inline}"
|
||
)
|
||
|
||
inject_runtime_user_message(
|
||
web_terminal=web_terminal,
|
||
messages=messages,
|
||
text=message,
|
||
source="background_command",
|
||
sender=sender,
|
||
conversation_id=conversation_id,
|
||
after_tool_call_id=after_tool_call_id,
|
||
inline=inline,
|
||
extra_metadata={"command_id": command_id},
|
||
)
|
||
try:
|
||
manager.mark_notified(str(command_id))
|
||
debug_log(f"[BgCmdDebug] mark_notified success command_id={command_id}")
|
||
except Exception:
|
||
debug_log(f"[BgCmdDebug] mark_notified failed command_id={command_id}")
|
||
pass
|
||
maybe_mark_failure_from_message(web_terminal, message)
|
||
|
||
|
||
|
||
async def wait_retry_delay(*, delay_seconds: int, client_sid: str, username: str, sender, get_stop_flag, clear_stop_flag) -> bool:
|
||
"""等待重试间隔,同时检查是否收到停止请求。"""
|
||
if delay_seconds <= 0:
|
||
return False
|
||
deadline = time.time() + delay_seconds
|
||
while time.time() < deadline:
|
||
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:
|
||
sender('task_stopped', {
|
||
'message': '命令执行被用户取消',
|
||
'reason': 'user_stop'
|
||
})
|
||
clear_stop_flag(client_sid, username)
|
||
return True
|
||
await asyncio.sleep(0.2)
|
||
return False
|
||
|
||
|
||
|
||
def cancel_pending_tools(*, tool_calls_list, sender, messages):
|
||
"""为尚未返回结果的工具生成取消结果,防止缺失 tool_call_id 造成后续 400。"""
|
||
if not tool_calls_list:
|
||
return
|
||
for tc in tool_calls_list:
|
||
tc_id = tc.get("id")
|
||
func_name = tc.get("function", {}).get("name")
|
||
sender('update_action', {
|
||
'preparing_id': tc_id,
|
||
'status': 'cancelled',
|
||
'result': {
|
||
"success": False,
|
||
"status": "cancelled",
|
||
"message": "命令执行被用户取消",
|
||
"tool": func_name
|
||
}
|
||
})
|
||
if tc_id:
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc_id,
|
||
"name": func_name,
|
||
"content": "命令执行被用户取消",
|
||
})
|