myagent-ai 1.13.1 → 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/context_builder.py +133 -3
- package/package.json +1 -1
- package/web/api_server.py +38 -7
- package/web/ui/chat/chat.css +1 -1
- package/web/ui/chat/chat_main.js +11 -10
- package/web/ui/chat/flow_engine.js +16 -0
package/core/context_builder.py
CHANGED
|
@@ -42,6 +42,27 @@ if TYPE_CHECKING:
|
|
|
42
42
|
logger = get_logger("myagent.context_builder")
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
# ── 知识库 RAG 索引缓存(模块级,避免每次 LLM 调用重建) ──
|
|
46
|
+
_rag_cache: dict = {} # {abs_kb_dir: {"rag": KnowledgeRAG, "mtime": str}}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _compute_dir_mtime(dir_path: str) -> str:
|
|
50
|
+
"""计算目录下所有支持文件的修改时间摘要(用于脏检测)"""
|
|
51
|
+
import os
|
|
52
|
+
_KB_EXTS = {".md", ".txt", ".json", ".csv", ".py", ".js", ".html"}
|
|
53
|
+
mtimes = []
|
|
54
|
+
try:
|
|
55
|
+
for f in sorted(os.listdir(dir_path)):
|
|
56
|
+
fp = os.path.join(dir_path, f)
|
|
57
|
+
if os.path.isfile(fp):
|
|
58
|
+
ext = os.path.splitext(f)[1].lower()
|
|
59
|
+
if ext in _KB_EXTS:
|
|
60
|
+
mtimes.append(f"{f}:{os.path.getmtime(fp)}")
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
return "|".join(sorted(mtimes))
|
|
64
|
+
|
|
65
|
+
|
|
45
66
|
# 默认知识库目录名(相对于 data_dir)
|
|
46
67
|
_DEFAULT_KB_RELATIVE_PATH = "knowledge"
|
|
47
68
|
|
|
@@ -143,6 +164,9 @@ class ContextBuilder:
|
|
|
143
164
|
context_body = "\n".join(sections)
|
|
144
165
|
context_xml = f"<context>\n{context_body}\n</context>"
|
|
145
166
|
|
|
167
|
+
# ── Token 预算检查与自动裁剪 ──
|
|
168
|
+
context_xml = self._enforce_token_budget(context_xml)
|
|
169
|
+
|
|
146
170
|
logger.debug(
|
|
147
171
|
f"上下文已构建 (session={session_id}, 对话条数={len(conversation_history)}, "
|
|
148
172
|
f"context长度={len(context_xml)})"
|
|
@@ -336,7 +360,10 @@ class ContextBuilder:
|
|
|
336
360
|
return "<knowledge>\n(未找到相关知识)\n</knowledge>"
|
|
337
361
|
|
|
338
362
|
def _search_knowledge_dir(self, kb_dir: str, query: str, top_k: int = 5) -> str:
|
|
339
|
-
"""在指定知识库目录中执行 RAG 搜索并格式化结果
|
|
363
|
+
"""在指定知识库目录中执行 RAG 搜索并格式化结果
|
|
364
|
+
|
|
365
|
+
使用模块级缓存 + 文件修改时间脏检测,避免每次 LLM 调用都重建索引。
|
|
366
|
+
"""
|
|
340
367
|
import os as _os
|
|
341
368
|
|
|
342
369
|
if not query.strip():
|
|
@@ -348,8 +375,30 @@ class ContextBuilder:
|
|
|
348
375
|
try:
|
|
349
376
|
from knowledge.rag import KnowledgeRAG
|
|
350
377
|
|
|
351
|
-
|
|
352
|
-
|
|
378
|
+
# ── 缓存键: 目录绝对路径 ──
|
|
379
|
+
abs_kb = _os.path.abspath(kb_dir)
|
|
380
|
+
cache = _rag_cache.get(abs_kb)
|
|
381
|
+
need_rebuild = True
|
|
382
|
+
|
|
383
|
+
if cache is not None:
|
|
384
|
+
# 脏检测: 比较上次记录的文件修改时间摘要
|
|
385
|
+
current_mtime = _compute_dir_mtime(abs_kb)
|
|
386
|
+
if current_mtime == cache["mtime"]:
|
|
387
|
+
need_rebuild = False
|
|
388
|
+
else:
|
|
389
|
+
logger.debug(f"知识库目录变更检测到 ({abs_kb}),重建索引")
|
|
390
|
+
|
|
391
|
+
if need_rebuild:
|
|
392
|
+
rag = KnowledgeRAG(kb_dir=kb_dir)
|
|
393
|
+
rag.build_index()
|
|
394
|
+
_rag_cache[abs_kb] = {
|
|
395
|
+
"rag": rag,
|
|
396
|
+
"mtime": _compute_dir_mtime(abs_kb),
|
|
397
|
+
}
|
|
398
|
+
rebuild_tag = "重新" if cache else ""
|
|
399
|
+
logger.debug(f"知识库索引已{rebuild_tag}构建: {rag.total_chunks} 块 ({abs_kb})")
|
|
400
|
+
else:
|
|
401
|
+
rag = cache["rag"]
|
|
353
402
|
|
|
354
403
|
if rag.total_chunks == 0:
|
|
355
404
|
return ""
|
|
@@ -648,6 +697,87 @@ class ContextBuilder:
|
|
|
648
697
|
lines.append("</tools>")
|
|
649
698
|
return "\n".join(lines)
|
|
650
699
|
|
|
700
|
+
# =========================================================================
|
|
701
|
+
# Token 预算管理
|
|
702
|
+
# =========================================================================
|
|
703
|
+
|
|
704
|
+
def _enforce_token_budget(self, context_xml: str, budget_ratio: float = 0.75) -> str:
|
|
705
|
+
"""
|
|
706
|
+
Token 预算检查与自动裁剪。
|
|
707
|
+
|
|
708
|
+
估算 context_xml 的 token 数,如果超过 budget_ratio * context_window,
|
|
709
|
+
按优先级裁剪(先裁剪 <knowledge>、<recall_memory>、<automemory>,
|
|
710
|
+
再裁剪 <resentdialog> 历史部分)。
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
context_xml: 完整的 <context> XML 字符串
|
|
714
|
+
budget_ratio: 上下文窗口使用比例上限(默认 75%,为系统提示和输出预留空间)
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
裁剪后的 context_xml
|
|
718
|
+
"""
|
|
719
|
+
if not context_xml:
|
|
720
|
+
return context_xml
|
|
721
|
+
|
|
722
|
+
# 粗略估算 token: 中文约 1.3 token/字,英文约 0.35 token/字
|
|
723
|
+
def _est_tok(text: str) -> int:
|
|
724
|
+
if not text:
|
|
725
|
+
return 0
|
|
726
|
+
cn = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
|
727
|
+
other = len(text) - cn
|
|
728
|
+
return int(cn * 1.3 + other * 0.35)
|
|
729
|
+
|
|
730
|
+
# 默认 128K context window
|
|
731
|
+
window = 128000
|
|
732
|
+
|
|
733
|
+
budget = int(window * budget_ratio)
|
|
734
|
+
estimated = _est_tok(context_xml)
|
|
735
|
+
|
|
736
|
+
if estimated <= budget:
|
|
737
|
+
return context_xml
|
|
738
|
+
|
|
739
|
+
logger.warning(
|
|
740
|
+
f"上下文 token 估算 ({estimated}) 超出预算 ({budget} = {budget_ratio}*{window}), "
|
|
741
|
+
f"启动自动裁剪 (原始长度={len(context_xml)} 字符)"
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
import re
|
|
745
|
+
|
|
746
|
+
def _remove_section(xml: str, tag: str) -> str:
|
|
747
|
+
pattern = rf'<{tag}>[\s\S]*?</{tag}>'
|
|
748
|
+
replacement = f'<{tag}>\n(因 token 预算不足已裁剪)\n</{tag}>'
|
|
749
|
+
return re.sub(pattern, replacement, xml, count=1, flags=re.DOTALL)
|
|
750
|
+
|
|
751
|
+
# 按优先级从低到高裁剪
|
|
752
|
+
for tag in ['knowledge', 'recall_memory', 'automemory']:
|
|
753
|
+
if estimated <= budget:
|
|
754
|
+
break
|
|
755
|
+
if f'<{tag}>' in context_xml:
|
|
756
|
+
context_xml = _remove_section(context_xml, tag)
|
|
757
|
+
estimated = _est_tok(context_xml)
|
|
758
|
+
logger.debug(f"裁剪 <{tag}> 后 token 估算: {estimated}")
|
|
759
|
+
|
|
760
|
+
# 如果还超预算,截断 <resentdialog> 内容
|
|
761
|
+
if estimated > budget:
|
|
762
|
+
pattern = r'<resentdialog>\n([\s\S]*?)\n</resentdialog>'
|
|
763
|
+
match = re.search(pattern, context_xml)
|
|
764
|
+
if match:
|
|
765
|
+
dialog_text = match.group(1)
|
|
766
|
+
target_chars = int(budget / 1.3)
|
|
767
|
+
if len(dialog_text) > target_chars:
|
|
768
|
+
truncated = dialog_text[-target_chars:]
|
|
769
|
+
truncated = "(... 历史已因 token 预算不足裁剪 ...)\n" + truncated
|
|
770
|
+
context_xml = (
|
|
771
|
+
context_xml[:match.start(1)] + truncated + context_xml[match.end(1):]
|
|
772
|
+
)
|
|
773
|
+
estimated = _est_tok(context_xml)
|
|
774
|
+
logger.debug(f"截断对话历史后 token 估算: {estimated}")
|
|
775
|
+
|
|
776
|
+
if estimated > budget:
|
|
777
|
+
logger.warning(f"上下文裁剪后仍超出预算 (token={estimated}/{budget})")
|
|
778
|
+
|
|
779
|
+
return context_xml
|
|
780
|
+
|
|
651
781
|
|
|
652
782
|
# =============================================================================
|
|
653
783
|
# 工具函数
|
package/package.json
CHANGED
package/web/api_server.py
CHANGED
|
@@ -155,6 +155,9 @@ class ApiServer:
|
|
|
155
155
|
self._msg_queues: Dict[str, List[Dict]] = {}
|
|
156
156
|
# 任务列表内存存储(exec 模式,替代 task.md)
|
|
157
157
|
self._task_list_store: dict[str, list] = {} # session_id -> [{text, status}] (per-session, not per-agent)
|
|
158
|
+
# 模型链并发锁:防止并发请求互相覆盖 self.core.llm 配置
|
|
159
|
+
import asyncio
|
|
160
|
+
self._model_chain_lock = asyncio.Lock()
|
|
158
161
|
self._setup_routes()
|
|
159
162
|
self._runner: Optional[web.AppRunner] = None
|
|
160
163
|
|
|
@@ -1031,23 +1034,25 @@ class ApiServer:
|
|
|
1031
1034
|
base_instruction = (
|
|
1032
1035
|
"你当前处于【执行模式】(Execution Mode)。\n\n"
|
|
1033
1036
|
"## 核心规则\n"
|
|
1034
|
-
"1.
|
|
1037
|
+
"1. **任务列表(按复杂度决定)**:\n"
|
|
1038
|
+
" - 如果用户的需求是简单任务(预计操作步骤不超过5步,如:单次查询、简单计算、问答题、格式转换、文件读取等),【不要】使用 ```tasklist```,直接用纯文本回复并执行即可。\n"
|
|
1039
|
+
" - 只有当任务较复杂(预计需要超过5步操作,如:多文件修改、需要调研+实现+测试、涉及多个模块联动等),才使用 ```tasklist``` 代码块来跟踪进度。\n"
|
|
1035
1040
|
" - 格式:```tasklist\\n[{\"text\": \"步骤描述\", \"status\": \"pending\"}]\\n```\n"
|
|
1036
1041
|
" - status 可选值:pending(待执行)、running(进行中)、done(已完成)、blocked(受阻)\n"
|
|
1037
|
-
" -
|
|
1042
|
+
" - 首次收到复杂任务时,拆分为多个步骤,全部标记为 pending\n"
|
|
1038
1043
|
" - 每次执行完一个步骤后,更新对应步骤状态为 done,下一个为 running\n"
|
|
1039
1044
|
"2. **单步执行(强制)**:每次回复【只能执行一个操作】(一个工具调用、一个代码块或一个技能调用)。\n"
|
|
1040
1045
|
" - 执行完一个操作后停下来,等待结果反馈后再决定下一步\n"
|
|
1041
1046
|
" - 不要一次性执行多个操作\n"
|
|
1042
|
-
"3. **回复格式**:先写纯文本分析/总结 →
|
|
1043
|
-
"4.
|
|
1047
|
+
"3. **回复格式**:先写纯文本分析/总结 → 如有任务列表则用 ```tasklist``` 更新进度 → 最后用 ```action``` 执行操作(如有)\n"
|
|
1048
|
+
"4. **任务完成**:当使用任务列表且所有步骤都标记为 done 时,用 ```action``` 输出 {\"type\": \"final_answer\", \"content\": \"...\"} 结束任务。简单任务直接回复即可。\n"
|
|
1044
1049
|
)
|
|
1045
1050
|
|
|
1046
1051
|
# 从内存读取当前任务列表(按 session 隔离)
|
|
1047
1052
|
store_key = session_id or agent_path
|
|
1048
1053
|
tasks = self._task_list_store.get(store_key, [])
|
|
1049
1054
|
if not tasks:
|
|
1050
|
-
return base_instruction + "\n## 当前状态\n
|
|
1055
|
+
return base_instruction + "\n## 当前状态\n暂无任务计划。如果是简单任务(不超过5步),直接执行即可,无需创建任务列表。如果是复杂任务(超过5步),请先分析用户需求,拆分为具体步骤,然后用 ```tasklist``` 输出计划。"
|
|
1051
1056
|
|
|
1052
1057
|
pending = [f" - ⏳ {t['text']}" for t in tasks if t.get("status") in ("pending", "running", "blocked")]
|
|
1053
1058
|
done = [f" - ✅ {t['text']}" for t in tasks if t.get("status") == "done"]
|
|
@@ -3054,10 +3059,22 @@ class ApiServer:
|
|
|
3054
3059
|
async def _try_model_chain(self, model_chain: list[dict], message: str, session_id: str,
|
|
3055
3060
|
agent_path: str = None, agent_system_prompt: str = None,
|
|
3056
3061
|
chat_mode: str = "") -> str:
|
|
3057
|
-
"""依次尝试模型链中的模型,直到成功或全部失败
|
|
3062
|
+
"""依次尝试模型链中的模型,直到成功或全部失败
|
|
3063
|
+
|
|
3064
|
+
使用 asyncio.Lock 保护共享的 self.core.llm,防止并发请求互相干扰。
|
|
3065
|
+
"""
|
|
3058
3066
|
if not model_chain:
|
|
3059
3067
|
return await self.core.process_message(message, session_id)
|
|
3060
3068
|
|
|
3069
|
+
async with self._model_chain_lock:
|
|
3070
|
+
return await self._try_model_chain_inner(model_chain, message, session_id,
|
|
3071
|
+
agent_path=agent_path, agent_system_prompt=agent_system_prompt,
|
|
3072
|
+
chat_mode=chat_mode)
|
|
3073
|
+
|
|
3074
|
+
async def _try_model_chain_inner(self, model_chain: list[dict], message: str, session_id: str,
|
|
3075
|
+
agent_path: str = None, agent_system_prompt: str = None,
|
|
3076
|
+
chat_mode: str = "") -> str:
|
|
3077
|
+
"""_try_model_chain 的实际执行体(已在 _model_chain_lock 保护下)"""
|
|
3061
3078
|
llm = self.core.llm
|
|
3062
3079
|
last_error = ""
|
|
3063
3080
|
used_model_name = ""
|
|
@@ -3152,12 +3169,26 @@ class ApiServer:
|
|
|
3152
3169
|
async def _try_model_chain_stream(self, model_chain, message, session_id,
|
|
3153
3170
|
agent_path=None, agent_system_prompt=None,
|
|
3154
3171
|
chat_mode="", stream_response=None):
|
|
3155
|
-
"""流式版本的模型链调用,逐token输出到SSE
|
|
3172
|
+
"""流式版本的模型链调用,逐token输出到SSE
|
|
3173
|
+
|
|
3174
|
+
使用 asyncio.Lock 保护共享的 self.core.llm,防止并发请求互相干扰。
|
|
3175
|
+
"""
|
|
3156
3176
|
if not model_chain:
|
|
3157
3177
|
result = await self.core.process_message(message, session_id)
|
|
3158
3178
|
await stream_response.write(("data: " + json.dumps({"type": "text", "content": result}) + "\n\n").encode())
|
|
3159
3179
|
return result
|
|
3160
3180
|
|
|
3181
|
+
async with self._model_chain_lock:
|
|
3182
|
+
return await self._try_model_chain_stream_inner(
|
|
3183
|
+
model_chain, message, session_id,
|
|
3184
|
+
agent_path=agent_path, agent_system_prompt=agent_system_prompt,
|
|
3185
|
+
chat_mode=chat_mode, stream_response=stream_response,
|
|
3186
|
+
)
|
|
3187
|
+
|
|
3188
|
+
async def _try_model_chain_stream_inner(self, model_chain, message, session_id,
|
|
3189
|
+
agent_path=None, agent_system_prompt=None,
|
|
3190
|
+
chat_mode="", stream_response=None):
|
|
3191
|
+
"""_try_model_chain_stream 的实际执行体(已在 _model_chain_lock 保护下)"""
|
|
3161
3192
|
llm = self.core.llm
|
|
3162
3193
|
full_text = ""
|
|
3163
3194
|
|
package/web/ui/chat/chat.css
CHANGED
package/web/ui/chat/chat_main.js
CHANGED
|
@@ -345,15 +345,10 @@ function initChat() {
|
|
|
345
345
|
document.getElementById('sendBtn').disabled = !this.value.trim();
|
|
346
346
|
saveDraft();
|
|
347
347
|
});
|
|
348
|
-
// Load task plan if in exec mode (
|
|
348
|
+
// Load task plan if in exec mode (panel stays hidden until tasks exist)
|
|
349
349
|
if (state.chatMode === 'exec') {
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
var taskBody = document.getElementById('taskBody');
|
|
353
|
-
var taskToggle = document.getElementById('taskToggle');
|
|
354
|
-
if (taskBody) taskBody.classList.remove('expanded');
|
|
355
|
-
if (taskToggle) taskToggle.classList.remove('expanded');
|
|
356
|
-
state.taskPanelExpanded = false;
|
|
350
|
+
// Don't show task panel by default - it will appear when the LLM creates a task list
|
|
351
|
+
// document.getElementById('taskPanel').classList.remove('hidden');
|
|
357
352
|
loadTaskPlan();
|
|
358
353
|
}
|
|
359
354
|
|
|
@@ -536,7 +531,10 @@ function setMode(mode) {
|
|
|
536
531
|
} else {
|
|
537
532
|
chatBtn.className = 'mode-btn';
|
|
538
533
|
execBtn.className = 'mode-btn active-exec';
|
|
539
|
-
|
|
534
|
+
// Only show task panel if tasks already exist (don't show empty panel)
|
|
535
|
+
if (state.taskItems.length > 0) {
|
|
536
|
+
taskPanel.classList.remove('hidden');
|
|
537
|
+
}
|
|
540
538
|
// Default collapsed when switching to exec mode
|
|
541
539
|
var tb = document.getElementById('taskBody');
|
|
542
540
|
var tt = document.getElementById('taskToggle');
|
|
@@ -642,7 +640,7 @@ async function loadTaskPlan() {
|
|
|
642
640
|
var data = await api(url);
|
|
643
641
|
state.taskItems = data.tasks || [];
|
|
644
642
|
renderTaskList();
|
|
645
|
-
//
|
|
643
|
+
// Show/hide task panel based on whether tasks exist
|
|
646
644
|
const panel = document.getElementById('taskPanel');
|
|
647
645
|
if (panel && state.taskItems.length > 0) {
|
|
648
646
|
panel.classList.remove('hidden');
|
|
@@ -651,6 +649,9 @@ async function loadTaskPlan() {
|
|
|
651
649
|
triggerTaskAutoFade();
|
|
652
650
|
}
|
|
653
651
|
// Don't auto-expand the body - user can click to expand manually
|
|
652
|
+
} else if (panel) {
|
|
653
|
+
// Hide panel when no tasks exist
|
|
654
|
+
panel.classList.add('hidden');
|
|
654
655
|
}
|
|
655
656
|
} catch (e) {
|
|
656
657
|
state.taskItems = [];
|
|
@@ -393,6 +393,8 @@ function updateStreamingMessage(msgIdx) {
|
|
|
393
393
|
const newText = msg.reasoning.substring(prevLen);
|
|
394
394
|
thoughtContent.insertAdjacentHTML('beforeend', renderMarkdown(newText));
|
|
395
395
|
reasoningDetails._lastReasoningLen = msg.reasoning.length;
|
|
396
|
+
// 自动滚动推理框内部内容到底部(不滚动整个页面)
|
|
397
|
+
thoughtContent.scrollTop = thoughtContent.scrollHeight;
|
|
396
398
|
}
|
|
397
399
|
} else if (thoughtContent && !msg.streaming) {
|
|
398
400
|
// Final render once streaming stops
|
|
@@ -443,6 +445,8 @@ function updateStreamingMessage(msgIdx) {
|
|
|
443
445
|
const newText = msg.thought.substring(prevLen);
|
|
444
446
|
thoughtContent.insertAdjacentHTML('beforeend', renderMarkdown(newText));
|
|
445
447
|
thoughtBlock._lastThoughtLen = msg.thought.length;
|
|
448
|
+
// 自动滚动思考框内部内容到底部
|
|
449
|
+
thoughtContent.scrollTop = thoughtContent.scrollHeight;
|
|
446
450
|
}
|
|
447
451
|
} else if (thoughtContent && !msg.streaming) {
|
|
448
452
|
thoughtContent.innerHTML = renderMarkdown(msg.thought);
|
|
@@ -499,6 +503,8 @@ function updateStreamingMessage(msgIdx) {
|
|
|
499
503
|
const newText = msg._v2Reasoning.substring(prevLen);
|
|
500
504
|
thoughtContent.insertAdjacentHTML('beforeend', renderMarkdown(newText));
|
|
501
505
|
v2ReasoningBlock._lastV2Len = msg._v2Reasoning.length;
|
|
506
|
+
// 自动滚动 V2 推理框内部内容到底部
|
|
507
|
+
thoughtContent.scrollTop = thoughtContent.scrollHeight;
|
|
502
508
|
}
|
|
503
509
|
} else if (thoughtContent && !msg.streaming) {
|
|
504
510
|
thoughtContent.innerHTML = renderMarkdown(msg._v2Reasoning);
|
|
@@ -1203,6 +1209,16 @@ async function sendMessage() {
|
|
|
1203
1209
|
sessionId = `${state.activeAgent}_web_${ts}`;
|
|
1204
1210
|
state.activeSessionId = sessionId;
|
|
1205
1211
|
document.getElementById('headerTitle').textContent = formatSessionName(sessionId);
|
|
1212
|
+
// ── 立即在左侧边栏添加新会话条目(不等后端返回) ──
|
|
1213
|
+
state.sessions.unshift({
|
|
1214
|
+
id: sessionId,
|
|
1215
|
+
name: formatSessionName(sessionId),
|
|
1216
|
+
messages: 0,
|
|
1217
|
+
last: new Date().toISOString(),
|
|
1218
|
+
preview: '',
|
|
1219
|
+
});
|
|
1220
|
+
state.agentSessions[state.activeAgent] = [...state.sessions];
|
|
1221
|
+
renderSessions();
|
|
1206
1222
|
// ── 更新 URL 参数,携带会话 ID(刷新页面可恢复) ──
|
|
1207
1223
|
try {
|
|
1208
1224
|
const url = new URL(window.location.href);
|