From 744cae8f54c8385d6c49e1db7ea34c9e467d7ff0 Mon Sep 17 00:00:00 2001 From: minorQAQ <202200120184@mail.sdu.edu.cn> Date: Tue, 11 Aug 2026 16:39:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9B=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent/agents/agent_runtime.py | 14 +- src/agent/config/env_config_dist.json | 4 +- src/agent/core/config_manager.py | 4 +- src/agent/core/graph.py | 14 +- src/agent/core/nodes.py | 236 +++++++++++++++++--------- src/agent/core/runtime.py | 67 ++++---- src/agent/core/state.py | 7 +- src/agent/memory/system_prompt.py | 49 +++--- src/agent/utils/agent_utils.py | 53 +++++- src/agent/utils/env_utils.py | 6 +- src/web/css/composer.css | 11 +- src/web/index.html | 6 +- src/web/js/settings.js | 55 +++++- src/web/js/token-bar.js | 125 +++++--------- src/web/server.py | 40 ++--- 15 files changed, 392 insertions(+), 299 deletions(-) diff --git a/src/agent/agents/agent_runtime.py b/src/agent/agents/agent_runtime.py index cfa4200..e60f780 100644 --- a/src/agent/agents/agent_runtime.py +++ b/src/agent/agents/agent_runtime.py @@ -85,13 +85,15 @@ def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime: else: agent_tools = all_tools - # 2.1 强制为主 Agent 加载 doc_tool(不可通过配置移除) + # 2.1 强制为主 Agent 加载 doc_tool 与 todo_list(不可通过配置移除) + # doc_tool: 文档读写必需;todo_list: Plan 模式第一步规划必需 if cfg.role == "main": - if not any(t.name == "doc_tool" for t in agent_tools): - for t in all_tools: - if t.name == "doc_tool": - agent_tools.append(t) - break + for force_name in ("doc_tool", "todo_list"): + if not any(t.name == force_name for t in agent_tools): + for t in all_tools: + if t.name == force_name: + agent_tools.append(t) + break # 3. bind_tools model_with_tools = agent_llm.bind_tools(agent_tools, tool_choice="auto") diff --git a/src/agent/config/env_config_dist.json b/src/agent/config/env_config_dist.json index 7292c10..ed530a7 100644 --- a/src/agent/config/env_config_dist.json +++ b/src/agent/config/env_config_dist.json @@ -8,9 +8,7 @@ "WEB_SEARCH_API_KEY": "", "WEB_SEARCH_ENGINE": "tavily", "LLM_CONTEXT_WINDOW": "262144", - "MINI_COMPRESS_RATE": "0.4", - "HARD_COMPRESS_RATE": "0.7", - "CURRENT_TURNS": "4", + "COMPRESS_RATE": "0.6", "IMG_SIZE": "768", "RAG_CHUNK_SIZE": "500", "RAG_CHUNK_OVERLAP": "50", diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py index 7b2505c..a84e108 100644 --- a/src/agent/core/config_manager.py +++ b/src/agent/core/config_manager.py @@ -362,9 +362,7 @@ def _default_env_config() -> dict: "RAG_BASE_URL": "", "IMAGE_GEN_URL": "http://localhost:8904", "LLM_CONTEXT_WINDOW": "262144", - "MINI_COMPRESS_RATE": "0.4", - "HARD_COMPRESS_RATE": "0.7", - "CURRENT_TURNS": "4", + "COMPRESS_RATE": "0.6", "IMG_SIZE": "768", "RAG_CHUNK_SIZE": "500", "RAG_CHUNK_OVERLAP": "50", diff --git a/src/agent/core/graph.py b/src/agent/core/graph.py index e463014..948ad37 100644 --- a/src/agent/core/graph.py +++ b/src/agent/core/graph.py @@ -6,7 +6,10 @@ 图结构: react: START -> agent -> (tools | END) - tools -> process_tool_artifact -> agent + tools -> process_tool_artifact -> compress -> agent + 其中 compress 为上下文压缩节点:agent 节点检测到 token 超阈值后标记, + 等本轮工具调用结束、产物解析完成后由 compress 节点统一压缩历史工具调用, + 再回到 agent 节点继续执行。 可扩展性: - 可增加 checkpoint、interrupt 节点、子图路由。 @@ -18,7 +21,7 @@ from typing import Any from langgraph.graph import END, START, StateGraph -from agent.core.nodes import make_call_model_node, process_tool_artifact +from agent.core.nodes import make_call_model_node, maybe_compress_context, process_tool_artifact from agent.core.routing import should_continue from agent.core.state import AgentState @@ -53,8 +56,13 @@ def build_agent_graph(tools: list[Any], model_with_tools: Any, trajectory_rounds workflow.add_node("process_tool_artifact", process_tool_artifact) + # 上下文压缩节点:agent 节点检测 token 超阈值后置位 _need_compress, + # 本轮工具调用执行完、产物解析后在此统一压缩,再回到 agent 节点。 + workflow.add_node("compress", maybe_compress_context) + workflow.add_edge(START, "agent") workflow.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END}) workflow.add_edge("tools", "process_tool_artifact") - workflow.add_edge("process_tool_artifact", "agent") + workflow.add_edge("process_tool_artifact", "compress") + workflow.add_edge("compress", "agent") return workflow.compile() diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index f38cbd2..e0137b2 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -21,7 +21,7 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from agent.core.loop_detector import build_loop_reflection_prompt from agent.core.state import AgentState -from agent.memory.system_prompt import PLAN_FIRST_CALL_PROMPT, REFLECTION_PROMPT +from agent.memory.system_prompt import REFLECTION_PROMPT from agent.utils.image_utils import image_bytes_to_openai_image_url_part from agent.utils.tool_call_utils import normalize_tool_call, extract_reasoning_text from langchain_core.messages import SystemMessage @@ -58,9 +58,11 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: 场景下上下文过长。 规则: - - 保留所有 SystemMessage、HumanMessage(非合成图片)、AIMessage、ToolMessage + - 保留所有 SystemMessage、HumanMessage(非合成)、AIMessage、ToolMessage - 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪 - 只对实际包含图片截图的轮次计数,无截图的轮次跳过不计数 + - 上一/几轮的反思提示词(reflection 标记)不进入本轮上下文 + - 带 tool_calls 的历史 AIMessage 仅保留工具调用,content 中的思考文本不进入上下文 """ if trajectory_rounds <= 0: return list(messages) @@ -73,6 +75,10 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: for i in range(len(messages) - 1, -1, -1): msg = messages[i] + # 上一/几轮的反思提示词不进入本轮上下文(每次 agent 调用前会注入当轮反思) + if isinstance(msg, HumanMessage) and msg.additional_kwargs.get("reflection"): + continue + # 合成图片消息:仅保留最近 trajectory_rounds 轮(有图片的轮才计数) if isinstance(msg, HumanMessage) and msg.additional_kwargs.get("synthetic") and _has_image_content(msg): if img_rounds_seen < trajectory_rounds: @@ -95,7 +101,18 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: kept_indices.add(i) - return [messages[i] for i in sorted(kept_indices)] + result = [messages[i] for i in sorted(kept_indices)] + + # 剥离历史模型思考文本:上一/几轮的思考不进入本轮上下文。 + # 带 tool_calls 的 AIMessage,其 content 仅是思维链,保留 tool_calls 即可 + # (思考内容已由 extract_reasoning_text 单独记录到前端 think 面板)。 + cleaned: list = [] + for m in result: + if isinstance(m, AIMessage) and getattr(m, "tool_calls", None) and getattr(m, "content", None): + cleaned.append(m.model_copy(update={"content": ""})) + else: + cleaned.append(m) + return cleaned def _get_todolist_status() -> str: @@ -141,10 +158,6 @@ def _build_reflection_prompt(agent_mode: str, is_first_call: bool, messages: lis """ todo_status = _get_todolist_status() - # Plan 模式首次调用:强制要求生成 todolist - if agent_mode == "plan" and is_first_call: - return PLAN_FIRST_CALL_PROMPT - # 非 plan 模式首次调用,不需要反思 if is_first_call: return None @@ -210,125 +223,167 @@ def _extract_total_tokens(response: Any) -> int: return usage.get("total_tokens", 0) if usage else 0 -def _compress_messages_in_loop(state: dict, response: Any) -> None: - """基于 LLM 返回的 token 用量,就地压缩 state["messages"]。 +def _compute_next_cursor(session_id: str) -> int: + """计算下一次压缩游标:当前轮次起点在全局工具调用序列中的索引。 - 读 response metadata 中的 ``total_tokens``,无需任何持久化字段。 - 旧消息被原地替换为 SystemMessage 摘要,新消息保留不变。 + 触发压缩时当前 turn 尚未持久化(save_turn 在图执行结束后才调用), + 因此游标 = 当前 turn 之前所有已完成轮次的工具调用总数。 + 游标之前的工具调用历史已包含在压缩摘要中,注入上下文时跳过。 + """ + try: + from agent.history.tool_call_recorder import list_turns + turns = list_turns(session_id, limit=100000) + return sum(len(t.get("tool_calls") or []) for t in turns) + except Exception: + return -1 - 同时将 total_tokens 存入 tool_call_recorder 供前端实时展示。 + +def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]: + """根据 LLM 返回的 token 用量判断下一步动作。 + + 返回值 (trigger, total_tokens, threshold): + "none" —— 未达阈值,无需处理。 + "compress"—— 主 Agent 达到阈值,标记压缩,由 compress 节点统一执行。 + "sub_oom" —— 子 Agent 达到阈值(子 Agent 不压缩),由 agent 节点注入 + 整理提示词,让子 Agent 整理任务进度直接返回主 Agent。 + + 同时将 total_tokens 存入 tool_call_recorder 供前端实时展示(仅主 Agent)。 """ total = _extract_total_tokens(response) if total <= 0: - return + return ("none", 0, 0) # 存储最新 token 用量供前端轮询(仅统计主 Agent,子 Agent 上下文不计入) - _sid = "" + is_sub = False try: from agent.history.tool_call_recorder import get_current_session, is_sub_agent_context, set_session_tokens _sid = get_current_session() or "" - if _sid and not is_sub_agent_context(): + is_sub = is_sub_agent_context() + if _sid and not is_sub: set_session_tokens(_sid, total) except Exception: pass - from agent.utils.env_utils import LLM_CONTEXT_WINDOW, HARD_COMPRESS_RATE, MINI_COMPRESS_RATE - light_thresh = int(LLM_CONTEXT_WINDOW * MINI_COMPRESS_RATE) - hard_thresh = int(LLM_CONTEXT_WINDOW * HARD_COMPRESS_RATE) + from agent.utils.env_utils import LLM_CONTEXT_WINDOW, COMPRESS_RATE + threshold = int(LLM_CONTEXT_WINDOW * COMPRESS_RATE) + if total <= threshold: + return ("none", total, threshold) + if is_sub: + return ("sub_oom", total, threshold) + return ("compress", total, threshold) - if total <= light_thresh: - return - messages: list = list(state["messages"]) +SUB_AGENT_OOM_PROMPT = """ +【系统指令 - 上下文超限】 +你的上下文 token 用量已达 {total_tokens},超过限制阈值 {threshold},继续调用工具可能导致溢出。 +请立即停止调用任何工具,整理当前任务的详细进度(已完成的工作、关键结果、遇到的问题及原因), +直接以文字形式回复主 Agent,不要再调用任何工具。 +""".strip() - # 找到最新一条非 synthetic 的 HumanMessage(当前用户输入位置) - last_user_idx = -1 - for i in range(len(messages) - 1, -1, -1): - if isinstance(messages[i], HumanMessage): - kw = getattr(messages[i], "additional_kwargs", {}) or {} - if not kw.get("synthetic"): - last_user_idx = i - break - if last_user_idx <= 0: - return - # 确定保留起点:hard 只保留 user 之后,light 保留本轮首个 agent 动作 - if total > hard_thresh: - keep_from = last_user_idx - else: - keep_from = last_user_idx - for i in range(last_user_idx - 1, 0, -1): - if isinstance(messages[i], AIMessage) and getattr(messages[i], "tool_calls", None): - keep_from = i - break +COMPRESSED_SUMMARY_PREFIX = "【历史工具调用摘要】" +COMPRESSED_SUMMARY_MSG_ID = "compressed_summary" - if keep_from <= 0: - return - # 构建待压缩文本 - to_compress = messages[:keep_from] - parts = [] - for m in to_compress: - if isinstance(m, SystemMessage): - parts.append(f"[系统] {getattr(m, 'content', '')}") - elif isinstance(m, HumanMessage): - kw = getattr(m, "additional_kwargs", {}) or {} - if not kw.get("synthetic"): - parts.append(f"用户: {getattr(m, 'content', '')}") - elif isinstance(m, AIMessage): - if hasattr(m, "tool_calls") and m.tool_calls: - names = [tc.get("name", "?") if isinstance(tc, dict) else getattr(tc, "name", "?") for tc in m.tool_calls] - parts.append(f"AI调用工具: {', '.join(names)}") - else: - parts.append(f"AI回复: {getattr(m, 'content', '')[:200]}") - elif isinstance(m, ToolMessage): - parts.append(f"工具({getattr(m, 'name', '?')})返回: {getattr(m, 'content', '')[:300]}") +def maybe_compress_context(state: AgentState) -> dict: + """上下文压缩节点:位于 process_tool_artifact 之后、agent 之前。 - compress_text = "\n".join(parts[-50:]) - if not compress_text.strip(): - return + 当 agent 节点检测到 token 超阈值(state["_need_compress"]=True)时, + 将上下文中注入的"历史工具调用与返回值"压缩为摘要(系统提示词、对话历史 + 均不压缩),更新压缩游标,并通过 RemoveMessage 移除已被摘要覆盖的历史工具 + 调用消息,替换为累积摘要 SystemMessage。 + + 输出: + {"messages": [RemoveMessage..., 摘要 SystemMessage], "_need_compress": False} + """ + if not state.get("_need_compress"): + return {} + + # 子 Agent 不做压缩(超限时已在 agent 节点直接报错) + sid = "" + try: + from agent.history.tool_call_recorder import get_current_session, is_sub_agent_context + if is_sub_agent_context(): + return {"_need_compress": False} + sid = get_current_session() or "" + except Exception: + return {"_need_compress": False} + + messages = list(state["messages"]) + ctx_msgs = [ + m for m in messages + if isinstance(m, HumanMessage) and (m.additional_kwargs or {}).get("tool_ctx") + ] + if not ctx_msgs: + return {"_need_compress": False} + + compress_text = "\n".join( + str(getattr(m, "content", "") or "").strip() for m in ctx_msgs + ).strip() + if not compress_text: + return {"_need_compress": False} - label = "全量" if total > hard_thresh else "轻量" before_len = len(messages) # SSE 通知前端:压缩开始 try: from agent.history.tool_call_recorder import record_tool_call_live - if _sid: - record_tool_call_live(_sid, "summarizer", { - "action": f"{label}压缩中", - "before_tokens": total, + if sid: + record_tool_call_live(sid, "summarizer", { + "action": "上下文压缩中", "before_messages": before_len, }) except Exception: pass - from agent.core.llm import llm as compress_llm - from agent.utils.agent_utils import summarize_chat_text - try: + from agent.core.llm import llm as compress_llm + from agent.utils.agent_utils import get_session_meta, summarize_chat_text, update_session_meta + summary_text = summarize_chat_text(compress_llm, compress_text) - compressed_msg = SystemMessage(content=f"【运行中上下文摘要】\n{summary_text}") - state["messages"] = [compressed_msg] + messages[keep_from:] - after_len = len(state["messages"]) + + # 累积压缩摘要并更新压缩游标(游标前历史已被摘要覆盖) + if sid: + meta = get_session_meta(sid, "main") + old_content = meta.get("compressed_content") or "" + new_content = (old_content + "\n\n" + summary_text).strip() if old_content else summary_text + cursor = _compute_next_cursor(sid) + update_session_meta(sid, "main", cursor, new_content) + else: + new_content = summary_text + + # 通过 RemoveMessage 移除已被摘要覆盖的历史工具调用消息, + # 并以固定 id 插入最新累积摘要(同 id 消息会被替换而非追加) + from langgraph.graph.message import RemoveMessage + updates: list = [ + RemoveMessage(id=m.id) + for m in ctx_msgs + if getattr(m, "id", None) + ] + updates.append(SystemMessage( + content=f"{COMPRESSED_SUMMARY_PREFIX}\n{new_content}", + id=COMPRESSED_SUMMARY_MSG_ID, + )) # SSE 通知前端:压缩完成 try: from agent.history.tool_call_recorder import record_tool_result_live - if _sid: - record_tool_result_live(_sid, "summarizer", - f"压缩完成: {before_len}→{after_len}条消息") + if sid: + record_tool_result_live(sid, "summarizer", + f"压缩完成: 压缩 {len(ctx_msgs)} 条历史工具调用消息") except Exception: pass + return {"messages": updates, "_need_compress": False} except Exception as e: # SSE 通知前端:压缩失败 try: from agent.history.tool_call_recorder import record_tool_result_live - if _sid: - record_tool_result_live(_sid, "summarizer", + if sid: + record_tool_result_live(sid, "summarizer", f"压缩失败: {str(e)}") except Exception: pass + return {"_need_compress": False} def make_call_model_node( @@ -381,9 +436,24 @@ def make_call_model_node( response = model_with_tools.invoke(messages_for_llm) - # 基于模型返回 token 用量的上下文中压缩(读 response metadata,不依赖 SQLite) - # 同时将 total_tokens 存入 session_tokens 供前端轮询 - _compress_messages_in_loop(state, response) + # 基于模型返回 token 用量判断:主 Agent 标记压缩 / 子 Agent 超限整理。 + # total_tokens 同时存入 session_tokens 供前端轮询(仅主 Agent)。 + trigger, _total, _threshold = _maybe_trigger_compress(response) + if trigger == "sub_oom": + # 子 Agent 不压缩:注入整理提示词重新调用,让模型整理任务进度直接 + # 返回主 Agent(提示词内附 OOM 溢出信息)。 + oom_msg = HumanMessage( + content=SUB_AGENT_OOM_PROMPT.format(total_tokens=_total, threshold=_threshold), + additional_kwargs={"synthetic": True, "reflection": True}, + ) + response = model_with_tools.invoke(list(messages_for_llm) + [oom_msg]) + # 剥离工具调用:确保子 Agent 以文字整理结果结束,不再进入工具循环 + if getattr(response, "tool_calls", None): + response = AIMessage(content=getattr(response, "content", "") or "(子Agent上下文超限,已完成任务整理)") + need_compress = False + else: + need_compress = (trigger == "compress") + state["_need_compress"] = need_compress # 提取模型真实输出的思考内容(content),记录到前端 think 面板 # 模型同时输出 content + tool_calls 时,content 就是其思维链 @@ -420,7 +490,7 @@ def make_call_model_node( except Exception: pass - return {"messages": [response]} + return {"messages": [response], "_need_compress": need_compress} return call_model diff --git a/src/agent/core/runtime.py b/src/agent/core/runtime.py index afaac20..5c216d1 100644 --- a/src/agent/core/runtime.py +++ b/src/agent/core/runtime.py @@ -24,7 +24,7 @@ from langchain_core.messages import messages_from_dict from agent.core.approvals import PENDING_TOOL_APPROVALS, remove_pending_ui_message, human_interaction_result from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, save_aborted_turn, set_current_turn, set_current_session -from agent.memory.system_prompt import PLAN_MODE_PROMPT, get_cron_system_prompt +from agent.memory.system_prompt import get_plan_system_prompt, get_cron_system_prompt from agent.core.loop_detector import reset_session as reset_loop_session, should_force_end as loop_should_force_end, build_force_end_message, clear_session as clear_loop_session from agent.core.routing import first_pending_tool_meta from agent.core.tool_policy import classify_tool_execution @@ -70,32 +70,36 @@ class AgentRuntime: _OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。请简化任务或增加 max_iterations 配置后重试。" -def _inject_previous_turn_context(session_id: str, messages: list) -> None: - """从最近5轮(不含当前轮)tool_calling 记录中读取工具调用上下文,注入为合成消息。""" +def _inject_summary_context(session_id: str, messages: list) -> None: + """注入压缩摘要(游标之前的工具调用历史已压缩进摘要,仅主 Agent)。""" + if not session_id: + return try: - from agent.history.tool_call_recorder import list_turns - # 取6轮(当前轮 + 前5轮历史) - turns = list_turns(session_id, limit=6) - if not turns: - return - # 跳过最近一轮(当前正在执行或刚结束的 turn),取前面最多5轮历史 - recent_turns = turns[1:6] if len(turns) > 1 else [] - if not recent_turns: - # 首次对话场景:只有1轮,用唯一那一轮 - recent_turns = [turns[0]] - for turn in recent_turns: - tool_calls = turn.get("tool_calls", []) - if not tool_calls: - continue - for tc in tool_calls: - tn = tc.get("name", "unknown") - ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300] - tr = str(tc.get("result_text", ""))[:500] - if tn and tr: - messages.append(HumanMessage( - content=f"[历史工具调用] {tn}({ta}) → {tr}", - additional_kwargs={"synthetic": True}, - )) + from agent.utils.agent_utils import get_session_meta + meta = get_session_meta(session_id, "main") + summary = meta.get("compressed_content") or "" + if summary: + # 固定 id:图内 compress 节点更新摘要时按同 id 替换,避免重复累积 + messages.append(SystemMessage( + content=f"【历史工具调用摘要】\n{summary}", + id="compressed_summary", + )) + except Exception: + pass + + +def _inject_tool_history_context(session_id: str, messages: list) -> None: + """注入压缩游标之后的全部轮次历史工具调用上下文(替代原"最近5轮"注入)。 + + 系统定位: + 上下文组成(用户方案): + 系统提示词 + 压缩摘要 + 对话历史 + 压缩游标后的工具调用历史 + 本轮反思。 + """ + if not session_id: + return + try: + from agent.utils.agent_utils import build_tool_history_messages + messages.extend(build_tool_history_messages(session_id, "main")) except Exception: pass @@ -440,19 +444,22 @@ def execute_agent( # 按运行模式选择系统提示词:plan/cron 使用专用提示词,其余用 runtime 默认 if agent_mode == "plan": - system_prompt = PLAN_MODE_PROMPT + system_prompt = get_plan_system_prompt() elif agent_mode == "cron": system_prompt = get_cron_system_prompt() else: system_prompt = runtime.system_prompt - # 从 turn 文件加载历史消息 + 注入上轮工具上下文(图内 _compress_messages_in_loop 负责运行时压缩) + # 从 turn 文件加载历史消息 + 注入压缩摘要与工具调用历史 + # 上下文组成:系统提示词 + 压缩摘要(若有) + 对话历史 + 压缩游标后的工具调用历史 + # 图内 compress 节点负责在 token 超阈值时压缩工具调用历史并更新游标 messages = [SystemMessage(content=system_prompt)] if session_id: + _inject_summary_context(session_id, messages) history = get_history(session_id) messages.extend(history.messages) - # 注入上一轮工具调用上下文(从 tool_calling 中读取) - _inject_previous_turn_context(session_id, messages) + # 注入压缩游标之后的所有轮次工具调用上下文 + _inject_tool_history_context(session_id, messages) messages.append(HumanMessage(content=user_content)) graph_config = { diff --git a/src/agent/core/state.py b/src/agent/core/state.py index 9643e3a..496bb36 100644 --- a/src/agent/core/state.py +++ b/src/agent/core/state.py @@ -10,7 +10,7 @@ """ from __future__ import annotations -from typing import Annotated, Literal, Sequence, TypedDict +from typing import Annotated, Literal, Optional, Sequence, TypedDict from langchain_core.messages import BaseMessage from langgraph.graph.message import add_messages @@ -22,9 +22,11 @@ class AgentState(TypedDict): 字段: messages: 对话消息序列,通过 ``add_messages`` 追加合并。 agent_mode: 运行模式 — "agent"(标准)| "plan"(先规划再执行)| "cron"(定时任务无人值守)。 + _need_compress: 本轮 LLM 调用 token 用量超过压缩阈值时由 agent 节点置位, + 压缩节点在工具执行/产物解析完成后消费并重置。 系统定位: - ReAct 循环(agent -> tools -> process_tool_artifact -> agent)的载体。 + ReAct 循环(agent -> tools -> process_tool_artifact -> compress -> agent)的载体。 可扩展性: 扩展字段时保持 TypedDict 与 reducer 注解一致,避免并发更新冲突。 @@ -32,3 +34,4 @@ class AgentState(TypedDict): messages: Annotated[Sequence[BaseMessage], add_messages] agent_mode: str # "agent" | "plan" + _need_compress: Optional[bool] # 缺省视为 False,由 agent 节点置位、压缩节点消费 diff --git a/src/agent/memory/system_prompt.py b/src/agent/memory/system_prompt.py index 9386cf1..3420f99 100644 --- a/src/agent/memory/system_prompt.py +++ b/src/agent/memory/system_prompt.py @@ -131,26 +131,33 @@ DB_SYSTEM_PROMT = """ 请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧! """.strip() -PLAN_MODE_PROMPT = """ -你是一个具备多模态理解能力的桌面操作助手,运行在 **Plan 模式**下。Plan 模式要求你在执行任务前先进行详细规划。 - +# Plan 模式核心规则(todo_list 强制绑定) +_PLAN_CORE_RULES = """ 【Plan 模式核心规则】 - **首要任务**:收到用户请求后,你必须**第一步就调用 todo_list 工具**,将用户的任务拆解为非常细致、可执行的步骤列表。每个步骤应该具体到单个操作(如"打开浏览器"、"搜索XXX"、"截取屏幕"、"分析结果"等)。 - **规划要求**:步骤拆解必须详尽,覆盖从开始到完成的完整流程。不要跳过任何中间步骤。步骤描述要清晰具体,便于后续逐一执行和跟踪。 - **规划完成后**:生成 todolist 后,向用户简要汇报规划结果,然后**立即开始按步骤执行**,不要等待用户确认。 - **执行过程中**:每完成一个步骤,调用 todo_list 标记该步骤为已完成(done_step),让用户实时看到进度。 - **任务完成后**:确认所有步骤已完成,向用户总结执行过程和结果。 - -【通用行为准则】 -- 你的首要职责是理解用户的意图,并高效地完成用户的任务。 -- 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图、OCR 等无关工具。 -- **【强制规则】当你被要求生成 PPT、制作演示文稿、写幻灯片、生成 PDF 报告、撰写 Markdown 文档等涉及文档产出的任务时,你的 todo_list 规划的第一步必须是调用 skill_router 查询 Skill。如果 skill_router 返回了匹配的 Skill,你必须将其视为最高优先级的执行规范,严格遵循 Skill 中定义的完整工作流,严禁跳过任何步骤,严禁用你自身的编程经验(如直接写 python-pptx 代码、用终端脚本批量生成等)替代 Skill 规范。** -- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。 -- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。 -- 每次工具调用后,向用户简要汇报当前进度和下一步计划,确保用户始终了解任务状态。 -- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。 """.strip() +# Plan 模式提示词 = Plan 模式核心规则 + 当前 Agent 模式基础提示词 +PLAN_MODE_PROMPT = _PLAN_CORE_RULES + "\n\n" + _BASE_PROMPT + + +def get_plan_system_prompt(workspace_dir: str | None = None) -> str: + """动态构建 Plan 模式系统提示词(含工作空间路径)。 + + 与 get_main_system_prompt 同构:Plan 模式核心规则 + Agent 模式基础提示词 + 工作空间配置。 + """ + try: + from agent.utils.env_utils import get_workspace_dir + ws_dir = workspace_dir or _current_workspace_dir or get_workspace_dir() + ws_info = _WS_TAIL.format(workspace_dir=ws_dir) + except Exception: + ws_info = "" + return PLAN_MODE_PROMPT + ws_info + CRON_MODE_PROMPT = """ 你是一个具备多模态理解能力的桌面操作助手,运行在 **定时任务(Cron)模式**下。该模式由定时器或用户在定时任务会话中触发,你将无人值守地自主完成预设任务。 @@ -178,25 +185,9 @@ CRON_MODE_PROMPT = """ - 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。 """.strip() -# ---------- 反思 / Plan 首轮注入提示(作为 synthetic HumanMessage 注入到对话上下文)---------- +# ---------- 反思注入提示(作为 synthetic HumanMessage 注入到对话上下文)---------- # 这些不是系统提示词,而是在 call_model 节点中动态拼入消息列表的引导文本。 -PLAN_FIRST_CALL_PROMPT = """ -【系统指令 - Plan 模式】 -你现在处于 Plan 模式。请**立即调用 todo_list 工具**,按照用户任务拆解为步骤列表。 -规划要求: -- 如果用户任务包含多个子任务,每个子任务都需要被拆解为一个步骤。 -- 每一步骤必须具体到GUI操作(如"打开XXX浏览器"、"搜索XXX"、"单击XXX"等),但注意必须是确定的GUI操作,不能是模糊的操作(如"找到XXX按钮"等)。 -- **重要:合并同类操作**。同一个页面/界面上的多个连续操作应合并为一条步骤,用"并"连接,而不是拆成多条。例如: - * "点击顶部搜索框,输入'燕三嘤嘤嘤'并点击搜索"(合并了点击、输入、搜索三个操作) - * "在搜索结果中找到燕三嘤嘤嘤并点击头像进入他的主页"(合并了查找和点击) - * "在他的主页点开视频列表里的第一个视频并等待播放"(合并了点击和验证) - * "打开哔哩哔哩网站"(单个操作不需要合并) - * 反面示例(过于细碎,禁止):"1.点击搜索框 2.输入文字 3.点击搜索按钮 4.找到用户 5.点击头像" -- 步骤覆盖从开始到完成的完整流程,不要遗漏任何中间步骤,然后立即开始执行第一步,无需等待用户确认。 -- 请注意:如果用户明确分条步骤,每个步骤都需要被拆解为一个步骤(但仍需合并同一页面的连续操作)。 -""".strip() - REFLECTION_PROMPT = """ 【重要:如果你判断不需要调用任何工具,请直接回复用户,不要输出任何反思内容!】 只有在需要调用工具时,才先进行简短反思(一句话即可),然后调用工具。格式如下: diff --git a/src/agent/utils/agent_utils.py b/src/agent/utils/agent_utils.py index 417bd23..1746c6a 100644 --- a/src/agent/utils/agent_utils.py +++ b/src/agent/utils/agent_utils.py @@ -27,12 +27,6 @@ from langchain_core.messages import messages_from_dict, messages_to_dict from langchain_core.prompts import ChatPromptTemplate from agent.memory.system_prompt import SUMMARY_SYSTEM_PROMPT -from agent.utils.env_utils import ( - LLM_CONTEXT_WINDOW, - MINI_COMPRESS_RATE, - HARD_COMPRESS_RATE, - CURRENT_TURNS, -) from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS # 音频文件扩展名(小写,含点) @@ -218,6 +212,51 @@ def update_session_meta(session_id: str, agent_name: str, _save_session_meta(session_id, meta) +def build_tool_history_messages(session_id: str, agent_key: str = "main") -> list: + """构建压缩游标之后的"历史工具调用与返回值"合成消息列表。 + + 输入: + session_id: 会话 ID。 + agent_key: session_meta 中的 agent 键(主 Agent 为 "main")。 + + 输出: + synthetic HumanMessage(带 ``tool_ctx`` 标记)列表;无历史或 + 游标之后无内容时为空列表。 + + 系统定位: + runtime.execute_agent 构建上下文时调用,替代原"最近5轮"注入, + 改为注入压缩游标之后的所有轮次工具调用历史(游标之前的部分已包含在 + 压缩摘要中,由 ``compressed_content`` 承载)。 + """ + if not session_id: + return [] + meta = get_session_meta(session_id, agent_key) + cursor = meta.get("cursor_index", -1) + result: list = [] + try: + from agent.history.tool_call_recorder import list_turns + turns = list(list_turns(session_id, limit=100000)) # 时间倒序 + turns.reverse() # 转为时间正序 + idx = 0 + for turn in turns: + for tc in turn.get("tool_calls") or []: + if idx >= cursor: + tn = tc.get("name", "unknown") + ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300] + tr = str(tc.get("result_text", "") or "")[:500] + if tn and tr: + # 分配稳定 id(基于全局工具调用序号),供压缩节点 RemoveMessage 精确移除 + result.append(HumanMessage( + content=f"[历史工具调用] {tn}({ta}) → {tr}", + additional_kwargs={"synthetic": True, "tool_ctx": True}, + id=f"tool_ctx_{idx}", + )) + idx += 1 + except Exception: + pass + return result + + # ---------- Turn 级消息存储 ---------- import threading as _threading @@ -391,7 +430,7 @@ def summarize_chat_text(llm, chat_text: str) -> str: 摘要字符串。 系统定位: - nodes._compress_messages_in_loop 与 server.api_manual_compress 的共享核心。 + nodes.maybe_compress_context(图内自动压缩节点)的摘要生成核心。 """ prompt = ChatPromptTemplate.from_messages([ ("system", SUMMARY_SYSTEM_PROMPT), diff --git a/src/agent/utils/env_utils.py b/src/agent/utils/env_utils.py index ceac0e7..e4d877e 100644 --- a/src/agent/utils/env_utils.py +++ b/src/agent/utils/env_utils.py @@ -52,7 +52,7 @@ def _refresh_globals(): global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT global STREAMING_TTS_URL, ASR_BASE_URL, RAG_BASE_URL - global LLM_CONTEXT_WINDOW, MINI_COMPRESS_RATE, HARD_COMPRESS_RATE, CURRENT_TURNS + global LLM_CONTEXT_WINDOW, COMPRESS_RATE global IMG_SIZE, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, GROUNDING_WIDTH, GROUNDING_HEIGHT global SEND_FILE_SIZE_LIMIT @@ -70,9 +70,7 @@ def _refresh_globals(): ASR_BASE_URL = _get_env("ASR_BASE_URL") RAG_BASE_URL = _get_env("RAG_BASE_URL") LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 262144)) - MINI_COMPRESS_RATE = float(os.getenv("MINI_COMPRESS_RATE", 0.4)) - HARD_COMPRESS_RATE = float(os.getenv("HARD_COMPRESS_RATE", 0.7)) - CURRENT_TURNS = int(os.getenv("CURRENT_TURNS", 4)) + COMPRESS_RATE = float(os.getenv("COMPRESS_RATE", 0.6)) IMG_SIZE = int(os.getenv("IMG_SIZE", 768)) RAG_CHUNK_SIZE = int(os.getenv("RAG_CHUNK_SIZE", 500)) RAG_CHUNK_OVERLAP = int(os.getenv("RAG_CHUNK_OVERLAP", 50)) diff --git a/src/web/css/composer.css b/src/web/css/composer.css index 0d1fa69..8c502de 100644 --- a/src/web/css/composer.css +++ b/src/web/css/composer.css @@ -1360,11 +1360,6 @@ z-index: 1; border-radius: 3px 0 0 3px; } -.token-bar-seg--yellow { - background: var(--warning, #f59e0b); - opacity: 0.7; - z-index: 1; -} .token-bar-seg--red { background: var(--danger, #ef4444); opacity: 0.85; @@ -1405,14 +1400,10 @@ box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); } -.token-handle--mini .token-handle-dot { +.token-handle--compress .token-handle-dot { background: var(--accent, #0e7490); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); } -.token-handle--hard .token-handle-dot { - background: var(--danger, #d4756b); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 25%, transparent); -} .token-handle.overshot .token-handle-dot { animation: handlePulse 0.6s ease-in-out infinite alternate; diff --git a/src/web/index.html b/src/web/index.html index 89d1f30..4ce6a87 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -231,13 +231,9 @@