修复一些已知问题

This commit is contained in:
minorQAQ committed 2026-08-11 16:39:11 +08:00
1 parent b59db6879c
commit 744cae8f54
15 files changed
+392 -299

No files matched your search

+8 -6
View File
@@ -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")
+1 -3
View File
@@ -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",
+1 -3
View File
@@ -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",
+11 -3
View File
@@ -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()
+153 -83
View File
@@ -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
+37 -30
View File
@@ -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 = {
+5 -2
View File
@@ -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 节点置位、压缩节点消费
+20 -29
View File
@@ -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 = """
【重要:如果你判断不需要调用任何工具,请直接回复用户,不要输出任何反思内容!】
只有在需要调用工具时,才先进行简短反思(一句话即可),然后调用工具。格式如下:
+46 -7
View File
@@ -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),
+2 -4
View File
@@ -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))
+1 -10
View File
@@ -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;
+1 -5
View File
@@ -231,13 +231,9 @@
<div class="token-bar" id="tokenBar">
<div class="token-bar-track" id="tokenBarTrack">
<div class="token-bar-seg token-bar-seg--green" id="tokenBarSegGreen"></div>
<div class="token-bar-seg token-bar-seg--yellow" id="tokenBarSegYellow"></div>
<div class="token-bar-seg token-bar-seg--red" id="tokenBarSegRed"></div>
<div class="token-bar-fill" id="tokenBarFill"></div>
<div class="token-handle token-handle--mini" id="tokenHandleMini" title="轻量压缩阈值(拖拽调整)">
<div class="token-handle-dot"></div>
</div>
<div class="token-handle token-handle--hard" id="tokenHandleHard" title="深度压缩阈值(拖拽调整)">
<div class="token-handle token-handle--compress" id="tokenHandleCompress" title="压缩阈值(拖拽调整)">
<div class="token-handle-dot"></div>
</div>
</div>
+48 -7
View File
@@ -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");
+45 -80
View File
@@ -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("保存压缩设置失败");
+13 -27
View File
@@ -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,
}