修复一些已知问题
This commit is contained in:
1 parent
b59db6879c
commit
744cae8f54
15 files changed
+392
-299
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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 节点置位、压缩节点消费
|
||||
@@ -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 = """
|
||||
【重要:如果你判断不需要调用任何工具,请直接回复用户,不要输出任何反思内容!】
|
||||
只有在需要调用工具时,才先进行简短反思(一句话即可),然后调用工具。格式如下:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user