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 @@
-
-
-
-
-
+
diff --git a/src/web/js/settings.js b/src/web/js/settings.js index 964eceb..dba5650 100644 --- a/src/web/js/settings.js +++ b/src/web/js/settings.js @@ -614,6 +614,43 @@ function renderGuiConfig() { } // ===== Env 配置渲染 ===== +// 环境变量解释(鼠标悬停变量名时显示) +const ENV_DESC = { + WORKING_DIR: "项目工作目录,Agent 产生的文件默认存放位置。", + WORKSPACE_DIR: "工作空间目录(相对 WORKING_DIR),留空则使用 WORKING_DIR。", + USER_PYTHON_PATH: "自定义 Python 解释器路径,供终端/脚本执行使用。", + STREAMING_TTS_URL: "语音合成(TTS)服务地址。", + ASR_BASE_URL: "语音识别(ASR)服务地址。", + RAG_BASE_URL: "RAG 检索服务地址。", + IMAGE_GEN_URL: "图片生成服务地址。", + WEB_SEARCH_API_KEY: "网络搜索 API Key。", + WEB_SEARCH_ENGINE: "网络搜索引擎(如 tavily)。", + LLM_CONTEXT_WINDOW: "LLM 上下文窗口大小(token),压缩阈值按窗口比例计算。", + COMPRESS_RATE: "上下文压缩阈值比例(0~1),token 用量超过 窗口×该值 时触发压缩。", + IMG_SIZE: "工具截图/图片缩放边长(像素),用于控制 token 消耗。", + RAG_CHUNK_SIZE: "RAG 文档分块大小(字符)。", + RAG_CHUNK_OVERLAP: "RAG 分块重叠大小(字符)。", + GROUNDING_WIDTH: "GUI 定位图像宽度(像素)。", + GROUNDING_HEIGHT: "GUI 定位图像高度(像素)。", + LOOP_DETECT_TODOLIST_STALE_ROUNDS: "TodoList 过期检测轮数,超过该轮数未更新视为失效。", + LOOP_DETECT_REPEATED_TOOL_WARN: "同一工具连续重复调用超过该次数时输出警告。", + LOOP_DETECT_REPEATED_TOOL_END: "同一工具连续重复调用超过该次数时强制终止任务。", + SEND_FILE_SIZE_LIMIT: "发送文件大小上限(MB)。", + STORAGE_BACKEND: "存储后端:json(本地文件)或 mysql(数据库)。", + MYSQL_HOST: "MySQL 主机地址。", + MYSQL_PORT: "MySQL 端口。", + MYSQL_USER: "MySQL 用户名。", + MYSQL_PASSWORD: "MySQL 密码。", + MYSQL_DATABASE: "MySQL 数据库名。", + EMAIL_ADDRESS: "邮箱地址(邮件发送账号)。", + EMAIL_AUTH_CODE: "邮箱 SMTP 授权码。", + CRON_TIME_PERIOD_MINUTES: "定时任务冲突检测的时间段长度(分钟)。", + LLM_BASE_URL: "LLM 服务地址。", + LLM_API_KEY: "LLM API Key。", + LLM_MODEL: "LLM 模型名。", + LLM_TIMEOUT: "LLM 请求超时(秒)。", +}; + // 构建 MySQL 连接配置子区块(仅在 STORAGE_BACKEND=mysql 时渲染) function buildMysqlConfigSection() { const section = document.createElement("div"); @@ -628,11 +665,11 @@ function buildMysqlConfigSection() { grid.className = "env-mysql-grid"; const fields = [ - { key: "MYSQL_HOST", label: "主机 (host)", type: "text" }, - { key: "MYSQL_PORT", label: "端口 (port)", type: "number" }, - { key: "MYSQL_USER", label: "用户名 (user)", type: "text" }, - { key: "MYSQL_PASSWORD", label: "密码 (password)", type: "password" }, - { key: "MYSQL_DATABASE", label: "数据库 (database)", type: "text" }, + { key: "MYSQL_HOST", label: "主机 (host)", type: "text", title: ENV_DESC.MYSQL_HOST }, + { key: "MYSQL_PORT", label: "端口 (port)", type: "number", title: ENV_DESC.MYSQL_PORT }, + { key: "MYSQL_USER", label: "用户名 (user)", type: "text", title: ENV_DESC.MYSQL_USER }, + { key: "MYSQL_PASSWORD", label: "密码 (password)", type: "password", title: ENV_DESC.MYSQL_PASSWORD }, + { key: "MYSQL_DATABASE", label: "数据库 (database)", type: "text", title: ENV_DESC.MYSQL_DATABASE }, ]; fields.forEach((f) => { @@ -640,6 +677,7 @@ function buildMysqlConfigSection() { wrap.className = "env-mysql-field"; const lbl = document.createElement("label"); lbl.textContent = f.label; + if (f.title) lbl.title = f.title; lbl.setAttribute("for", `envMysql_${f.key}`); const inp = document.createElement("input"); inp.id = `envMysql_${f.key}`; @@ -834,10 +872,12 @@ function renderEnvConfig() { row.className = "env-config-item"; const lbl = document.createElement("label"); lbl.textContent = key; + if (ENV_DESC[key]) lbl.title = ENV_DESC[key]; // 定时任务存储后端:用下拉选择而非纯文本框 if (key === "STORAGE_BACKEND") { - lbl.textContent = "存储后端"; + lbl.textContent = "Storage Backend"; + if (ENV_DESC.STORAGE_BACKEND) lbl.title = ENV_DESC.STORAGE_BACKEND; const sel = document.createElement("select"); const optJson = document.createElement("option"); optJson.value = "json"; optJson.textContent = "JSON(本地文件)"; @@ -864,7 +904,8 @@ function renderEnvConfig() { // 定时任务时间段长度:数字输入 + 「应用」按钮(热更新调度器 + 持久化) if (key === "CRON_TIME_PERIOD_MINUTES") { - lbl.textContent = "时间段长度(分钟)"; + lbl.textContent = "Cron Period (minutes)"; + if (ENV_DESC.CRON_TIME_PERIOD_MINUTES) lbl.title = ENV_DESC.CRON_TIME_PERIOD_MINUTES; const wrap = document.createElement("div"); wrap.style.cssText = "display:flex;gap:0.5rem;align-items:center;flex:1;"; const numInp = document.createElement("input"); diff --git a/src/web/js/token-bar.js b/src/web/js/token-bar.js index a4da7b9..62f4ed6 100644 --- a/src/web/js/token-bar.js +++ b/src/web/js/token-bar.js @@ -1,19 +1,19 @@ -// token-bar.js -- Token 上下文进度条(含压缩游标 + 手动压缩) +// token-bar.js -- Token 上下文进度条(压缩阈值游标) // 在输入框下方展示当前 token 用量 / 最大上下文的动态进度条, -// 两个可拖拽游标对应 MINI_COMPRESS_RATE / HARD_COMPRESS_RATE +// 单个可拖拽游标对应 COMPRESS_RATE 压缩阈值 import { $, showToast } from './utils.js'; import { api } from './api.js'; import { state } from './state.js'; let _pollTimer = null; -let _settings = null; // { context_window, mini_rate, hard_rate, current_turns } +let _settings = null; // { context_window, compress_rate } let _currentTokens = 0; -let _dragging = null; // 'mini' | 'hard' | null +let _dragging = false; // ===== DOM refs ===== -let _barTrack, _barFill, _barSegGreen, _barSegYellow, _barSegRed; -let _handleMini, _handleHard; +let _barTrack, _barFill, _barSegGreen, _barSegRed; +let _handleCompress; let _labelText; // ===== 初始化 ===== @@ -21,10 +21,8 @@ export function initTokenBar() { _barTrack = $("tokenBarTrack"); _barFill = $("tokenBarFill"); _barSegGreen = $("tokenBarSegGreen"); - _barSegYellow = $("tokenBarSegYellow"); _barSegRed = $("tokenBarSegRed"); - _handleMini = $("tokenHandleMini"); - _handleHard = $("tokenHandleHard"); + _handleCompress = $("tokenHandleCompress"); _labelText = $("tokenLabel"); if (!_barTrack) return; @@ -49,7 +47,7 @@ async function loadSettings() { const data = await api("/api/config/compress-settings"); _settings = data; } catch { - _settings = { context_window: 131072, mini_compress_rate: 0.4, hard_compress_rate: 0.7 }; + _settings = { context_window: 262144, compress_rate: 0.6 }; } } @@ -71,8 +69,7 @@ async function pollTokens() { if (data.context_window) { _settings = { context_window: data.context_window, - mini_rate: data.mini_rate, - hard_rate: data.hard_rate, + compress_rate: data.compress_rate, }; } updateBarUI(); @@ -85,46 +82,38 @@ async function pollTokens() { function updateBarUI() { if (!_barTrack || !_settings) return; - const cw = _settings.context_window || 131072; - const miniRate = _settings.mini_rate || 0.4; - const hardRate = _settings.hard_rate || 0.7; + const cw = _settings.context_window || 262144; + const compressRate = _settings.compress_rate || 0.6; const tokens = _currentTokens; // 进度条填充位置(cap at 100%) const pct = Math.min(100, (tokens / cw) * 100); - const miniPct = miniRate * 100; - const hardPct = hardRate * 100; + const compressPct = compressRate * 100; - // 颜色分段宽仅延伸到实际 token 用量位置 - const greenW = Math.min(pct, miniPct); - const yellowW = Math.max(0, Math.min(pct - miniPct, hardPct - miniPct)); - const redW = Math.max(0, pct - hardPct); + // 颜色分段:低于压缩阈值绿色,超过阈值红色 + const greenW = Math.min(pct, compressPct); + const redW = Math.max(0, pct - compressPct); if (_barSegGreen) _barSegGreen.style.width = `${greenW}%`; - if (_barSegYellow) _barSegYellow.style.width = `${yellowW}%`; if (_barSegRed) _barSegRed.style.width = `${redW}%`; // 填充条隐藏(分段已承担可视化角色) if (_barFill) _barFill.style.width = '0%'; // 游标位置 - if (_handleMini) _handleMini.style.left = `${miniPct}%`; - if (_handleHard) _handleHard.style.left = `${hardPct}%`; + if (_handleCompress) _handleCompress.style.left = `${compressPct}%`; // 标签文字 if (_labelText) { _labelText.textContent = `${_fmtNum(tokens)} / ${_fmtNum(cw)} tokens`; // 颜色提示 - const cls = tokens > cw * hardRate ? 'danger' : tokens > cw * miniRate ? 'warn' : 'safe'; + const cls = tokens > cw * compressRate ? 'danger' : 'safe'; _labelText.className = `token-label token-label--${cls}`; } - // 游标超出反馈 - if (_handleMini) { - _handleMini.classList.toggle('overshot', tokens > cw * miniRate); - } - if (_handleHard) { - _handleHard.classList.toggle('overshot', tokens > cw * hardRate); + // 游标超限反馈 + if (_handleCompress) { + _handleCompress.classList.toggle('overshot', tokens > cw * compressRate); } } @@ -135,31 +124,24 @@ function _fmtNum(n) { // ===== 游标拖拽 ===== function attachHandleEvents() { - if (!_handleMini || !_handleHard || !_barTrack) return; + if (!_handleCompress || !_barTrack) return; - const handles = [ - { el: _handleMini, key: 'mini' }, - { el: _handleHard, key: 'hard' }, - ]; + _handleCompress.addEventListener('mousedown', (e) => { + e.preventDefault(); + _dragging = true; + _handleCompress.classList.add('dragging'); + document.addEventListener('mousemove', onDragMove); + document.addEventListener('mouseup', onDragEnd); + }); - for (const h of handles) { - h.el.addEventListener('mousedown', (e) => { - e.preventDefault(); - _dragging = h.key; - h.el.classList.add('dragging'); - document.addEventListener('mousemove', onDragMove); - document.addEventListener('mouseup', onDragEnd); - }); - - // 触摸支持 - h.el.addEventListener('touchstart', (e) => { - e.preventDefault(); - _dragging = h.key; - h.el.classList.add('dragging'); - document.addEventListener('touchmove', onTouchMove, { passive: false }); - document.addEventListener('touchend', onDragEndTouch); - }); - } + // 触摸支持 + _handleCompress.addEventListener('touchstart', (e) => { + e.preventDefault(); + _dragging = true; + _handleCompress.classList.add('dragging'); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onDragEndTouch); + }); } function getPctFromEvent(clientX) { @@ -170,14 +152,14 @@ function getPctFromEvent(clientX) { function onDragMove(e) { if (!_dragging) return; const pct = getPctFromEvent(e.clientX); - setHandlePct(_dragging, pct); + setHandlePct(pct); } function onTouchMove(e) { if (!_dragging) return; e.preventDefault(); const pct = getPctFromEvent(e.touches[0].clientX); - setHandlePct(_dragging, pct); + setHandlePct(pct); } function onDragEnd() { @@ -194,40 +176,23 @@ function onDragEndTouch() { function endDrag() { if (!_dragging) return; - const key = _dragging; - _dragging = null; - if (_handleMini) _handleMini.classList.remove('dragging'); - if (_handleHard) _handleHard.classList.remove('dragging'); + _dragging = false; + if (_handleCompress) _handleCompress.classList.remove('dragging'); // 保存到后端 - saveRateSetting(key); + saveRateSetting(); } -function setHandlePct(key, pct) { +function setHandlePct(pct) { const rate = Math.round(pct) / 100; - if (key === 'mini') { - _settings.mini_rate = rate; - // mini 不能超过 hard - if (_settings.hard_rate && rate >= _settings.hard_rate) { - _settings.hard_rate = Math.min(1, rate + 0.05); - } - } else if (key === 'hard') { - _settings.hard_rate = rate; - // hard 不能小于 mini - if (_settings.mini_rate && rate <= _settings.mini_rate) { - _settings.mini_rate = Math.max(0.05, rate - 0.05); - } - } + _settings.compress_rate = rate; updateBarUI(); } -async function saveRateSetting(key) { - const body = {}; - if (key === 'mini') body.mini_compress_rate = _settings.mini_rate; - if (key === 'hard') body.hard_compress_rate = _settings.hard_rate; +async function saveRateSetting() { try { await api("/api/config/compress-settings", { method: "POST", - body: JSON.stringify(body), + body: JSON.stringify({ compress_rate: _settings.compress_rate }), }); } catch { showToast("保存压缩设置失败"); diff --git a/src/web/server.py b/src/web/server.py index 818afc9..1957216 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -1404,35 +1404,26 @@ def api_reload_configs() -> dict[str, Any]: def api_get_compress_settings() -> dict[str, Any]: """获取压缩相关设置。 - 输出: {"context_window": int, "mini_compress_rate": float, - "hard_compress_rate": float, "current_turns": int} + 输出: {"context_window": int, "compress_rate": float} """ - from agent.utils.env_utils import ( - LLM_CONTEXT_WINDOW, MINI_COMPRESS_RATE, - HARD_COMPRESS_RATE, CURRENT_TURNS - ) + from agent.utils.env_utils import LLM_CONTEXT_WINDOW, COMPRESS_RATE return { "context_window": LLM_CONTEXT_WINDOW, - "mini_compress_rate": MINI_COMPRESS_RATE, - "hard_compress_rate": HARD_COMPRESS_RATE, - "current_turns": CURRENT_TURNS, + "compress_rate": COMPRESS_RATE, } @app.post("/api/config/compress-settings") def api_save_compress_settings(data: dict[str, Any]) -> dict[str, Any]: - """更新压缩参数(MINI_COMPRESS_RATE / HARD_COMPRESS_RATE)。 + """更新压缩参数(COMPRESS_RATE)。 - 输入: {"mini_compress_rate": float, "hard_compress_rate": float} + 输入: {"compress_rate": float} 输出: {"success": bool} """ config = load_env_config() updated = False - if "mini_compress_rate" in data: - config["MINI_COMPRESS_RATE"] = str(data["mini_compress_rate"]) - updated = True - if "hard_compress_rate" in data: - config["HARD_COMPRESS_RATE"] = str(data["hard_compress_rate"]) + if "compress_rate" in data: + config["COMPRESS_RATE"] = str(data["compress_rate"]) updated = True if updated: save_env_config(config) @@ -1446,26 +1437,21 @@ def api_save_compress_settings(data: dict[str, Any]) -> dict[str, Any]: def api_get_session_tokens(session_id: str) -> dict[str, Any]: """获取当前会话的 token 用量(来自 LLM response usage_metadata.total_tokens)。 - 输出: {"tokens": int, "context_window": int, "mini_threshold": int, - "hard_threshold": int, "mini_rate": float, "hard_rate": float} + 输出: {"tokens": int, "context_window": int, "compress_threshold": int, + "compress_rate": float} """ - from agent.utils.env_utils import ( - LLM_CONTEXT_WINDOW, MINI_COMPRESS_RATE, HARD_COMPRESS_RATE - ) + from agent.utils.env_utils import LLM_CONTEXT_WINDOW, COMPRESS_RATE from agent.history.tool_call_recorder import get_session_tokens # 直接读取 LLM 每次调用后存储的真实 total_tokens tokens = get_session_tokens(session_id) - mini_threshold = int(LLM_CONTEXT_WINDOW * MINI_COMPRESS_RATE) - hard_threshold = int(LLM_CONTEXT_WINDOW * HARD_COMPRESS_RATE) + compress_threshold = int(LLM_CONTEXT_WINDOW * COMPRESS_RATE) return { "tokens": tokens, "context_window": LLM_CONTEXT_WINDOW, - "mini_threshold": mini_threshold, - "hard_threshold": hard_threshold, - "mini_rate": MINI_COMPRESS_RATE, - "hard_rate": HARD_COMPRESS_RATE, + "compress_threshold": compress_threshold, + "compress_rate": COMPRESS_RATE, }