myagent-ai 1.10.0 → 1.10.1

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.
@@ -323,12 +323,29 @@ class MainAgent(BaseAgent):
323
323
 
324
324
  conversation_history = list(context.conversation_history or [])
325
325
 
326
+ # 从 DB 加载历史对话(如果 conversation_history 为空且 memory 可用)
327
+ if self.memory and not conversation_history:
328
+ try:
329
+ db_history = self.memory.get_conversation(
330
+ session_id=context.session_id,
331
+ limit=100,
332
+ )
333
+ if db_history:
334
+ conversation_history = [
335
+ Message(role=entry.role, content=entry.content)
336
+ for entry in db_history
337
+ ]
338
+ logger.info(f"[{task_id}] 从 DB 加载了 {len(conversation_history)} 条历史对话")
339
+ except Exception as e:
340
+ logger.warning(f"[{task_id}] 加载历史对话失败: {e}")
341
+
326
342
  # 保存用户消息到短期记忆
327
343
  if self.memory:
328
344
  self.memory.add_short_term(
329
345
  session_id=context.session_id,
330
346
  role="user",
331
347
  content=context.user_message,
348
+ key="user_input",
332
349
  )
333
350
 
334
351
  # 加载相关记忆 (recall from previous round or initial load)
@@ -391,7 +408,6 @@ class MainAgent(BaseAgent):
391
408
  + self.SYSTEM_PROMPT.split("\n", 1)[1]
392
409
  )
393
410
  system_content = _prompt_with_placeholder.replace(_CONTEXT_PLACEHOLDER, context_xml)
394
- print(system_content)
395
411
  # Step 3: 调用 LLM
396
412
  messages = [Message(role="system", content=system_content)]
397
413
 
@@ -420,6 +436,16 @@ class MainAgent(BaseAgent):
420
436
  llm_raw = response.content
421
437
  logger.debug(f"[{task_id}] LLM 输出 (前500字): {llm_raw[:500]}")
422
438
 
439
+ # 保存 LLM 原始输出到短期记忆(用于回溯和审计)
440
+ if self.memory:
441
+ self.memory.add_short_term(
442
+ session_id=context.session_id,
443
+ role="assistant",
444
+ content=llm_raw,
445
+ key="llm_output",
446
+ importance=0.3,
447
+ )
448
+
423
449
  # Step 4: 解析结构化输出
424
450
  parsed = parse_output(llm_raw)
425
451
 
@@ -667,11 +693,30 @@ class MainAgent(BaseAgent):
667
693
  )
668
694
 
669
695
  # 发送工具结果事件
696
+ # 提取实际输出:SkillResult 有 output/message/data,ExecResult 有 stdout/stderr
697
+ def _extract_tool_output(tr):
698
+ """从工具结果中提取实际输出文本"""
699
+ out = tr.get("output", "")
700
+ if out:
701
+ return out
702
+ out = tr.get("message", "")
703
+ if out:
704
+ return out
705
+ out = tr.get("stdout", "")
706
+ if out:
707
+ return out
708
+ data = tr.get("data")
709
+ if data is not None:
710
+ return str(data) if not isinstance(data, str) else data
711
+ return tr.get("error", "")
712
+
713
+ tool_output_text = _extract_tool_output(tool_result)
714
+
670
715
  await self._emit_v2_event(
671
716
  "v2_tool_result",
672
717
  {"tool": {"toolname": tool_name}, "result": {
673
718
  "success": tool_result.get("success", False),
674
- "output": truncate_str(tool_result.get("output", ""), 3000),
719
+ "output": truncate_str(tool_output_text, 3000),
675
720
  "error": truncate_str(tool_result.get("error", ""), 1000),
676
721
  "timed_out": tool_result.get("timed_out", False),
677
722
  }},
@@ -682,7 +727,7 @@ class MainAgent(BaseAgent):
682
727
  "title": f"工具结果: {tool_name}",
683
728
  "tool_name": tool_name,
684
729
  "success": tool_result.get("success", False),
685
- "summary": truncate_str(str(tool_result.get("output", tool_result.get("error", ""))), 500),
730
+ "summary": truncate_str(tool_output_text, 500),
686
731
  "result": tool_result,
687
732
  })
688
733
 
@@ -693,7 +738,7 @@ class MainAgent(BaseAgent):
693
738
  elif should_callback:
694
739
  need_callback = True
695
740
 
696
- output_str = tool_result.get("output", "") or tool_result.get("error", "")
741
+ output_str = tool_output_text
697
742
  tool_outputs_parts.append(
698
743
  f"### {before_call}\n"
699
744
  f"**工具**: {tool_name}\n"
@@ -710,6 +755,23 @@ class MainAgent(BaseAgent):
710
755
  content=f"[工具 {tool_name} 执行完成] {'成功' if tool_result.get('success') else '失败'}",
711
756
  ))
712
757
 
758
+ # 保存工具调用到短期记忆
759
+ if self.memory:
760
+ self.memory.add_short_term(
761
+ session_id=context.session_id,
762
+ role="assistant",
763
+ content=f"调用工具: {tool_name}\n参数: {truncate_str(parms, 1000)}",
764
+ key="tool_call",
765
+ importance=0.4,
766
+ )
767
+ self.memory.add_short_term(
768
+ session_id=context.session_id,
769
+ role="tool",
770
+ content=f"[{tool_name}] {'成功' if tool_result.get('success') else '失败'}\n{truncate_str(output_str, 5000)}",
771
+ key="tool_result",
772
+ importance=0.4,
773
+ )
774
+
713
775
  all_tool_outputs = "\n".join(tool_outputs_parts)
714
776
 
715
777
  # Step 12: 工具执行完毕后,根据 finish 标志决定是否回调 LLM
@@ -118,10 +118,11 @@ class ContextBuilder:
118
118
  kb_query = get_knowledge.strip() if get_knowledge else query
119
119
 
120
120
  sections: List[str] = [
121
+ self._build_datetime(),
121
122
  self._build_whomi(agent_name, agent_description, agent_override_prompt),
122
123
  self._build_memory(query, session_id),
123
124
  self._build_knowledge(kb_query),
124
- self._build_recent_dialog(conversation_history, self.max_dialog_chars),
125
+ self._build_recent_dialog(conversation_history, self.max_dialog_chars, session_id),
125
126
  self._build_user_input(user_typed_text, user_voice_text),
126
127
  self._build_task_plan(task_plan),
127
128
  self._build_tools(self.skill_registry),
@@ -140,6 +141,24 @@ class ContextBuilder:
140
141
  # 各段落构建方法
141
142
  # =========================================================================
142
143
 
144
+ def _build_datetime(self) -> str:
145
+ """
146
+ 构建 <datetime> 段落 —— 当前日期时间(精确到秒)。
147
+ 让 LLM 知道当前时间,以便给出与时间相关的回答。
148
+ """
149
+ from datetime import datetime
150
+ now = datetime.now()
151
+ weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
152
+ date_str = now.strftime("%Y年%m月%d日")
153
+ time_str = now.strftime("%H:%M:%S")
154
+ weekday = weekdays[now.weekday()]
155
+ return (
156
+ f"<datetime>\n"
157
+ f"当前时间: {date_str} {weekday} {time_str}\n"
158
+ f"时间戳: {now.timestamp()}\n"
159
+ f"</datetime>"
160
+ )
161
+
143
162
  def _build_whomi(
144
163
  self,
145
164
  agent_name: str,
@@ -275,16 +294,18 @@ class ContextBuilder:
275
294
  self,
276
295
  conversation_history: List["Message"],
277
296
  max_chars: int,
297
+ session_id: str = "",
278
298
  ) -> str:
279
299
  """
280
300
  构建 <resentdialog> 段落 —— 近期对话历史。
281
301
 
282
- 将对话格式化为带角色标签的文本,并截断到指定最大字符数。
283
- 保留 agent (assistant) 和 user 的消息,使用中文标签。
302
+ 将对话格式化为带角色标签的文本。当历史过长时,将较早的消息
303
+ 压缩为摘要,保留近期消息完整呈现,总字符数不超过 max_chars。
284
304
 
285
305
  Args:
286
306
  conversation_history: 对话历史消息列表
287
307
  max_chars: 最大字符数限制
308
+ session_id: 会话 ID(用于 MemoryManager 摘要)
288
309
 
289
310
  Returns:
290
311
  <resentdialog> XML 段落字符串
@@ -300,32 +321,126 @@ class ContextBuilder:
300
321
  "tool": "工具",
301
322
  }
302
323
 
303
- # 格式化每条消息
304
- formatted_lines: List[str] = []
324
+ # 过滤空消息并格式化
325
+ filtered_msgs: List[tuple] = []
305
326
  for msg in conversation_history:
306
327
  role = getattr(msg, "role", "user")
307
328
  content = getattr(msg, "content", "")
308
329
  if not content.strip():
309
330
  continue
310
-
311
331
  label = role_labels.get(role, role)
312
- formatted_lines.append(f"[{label}] {_xml_escape(content.strip())}")
332
+ filtered_msgs.append((label, content.strip()))
333
+
334
+ if not filtered_msgs:
335
+ return "<resentdialog>\n(无对话历史)\n</resentdialog>"
336
+
337
+ # 当消息超过阈值时,将旧消息压缩为摘要
338
+ SUMMARY_THRESHOLD = 30 # 超过30条时启用摘要
339
+ RECENT_KEEP = 15 # 保留最近15条完整消息
340
+ SUMMARY_BUDGET = 3000 # 摘要最大字符数
341
+
342
+ prefix_text = ""
343
+ recent_msgs = filtered_msgs
344
+
345
+ if len(filtered_msgs) > SUMMARY_THRESHOLD:
346
+ old_msgs = filtered_msgs[:-RECENT_KEEP]
347
+ recent_msgs = filtered_msgs[-RECENT_KEEP:]
348
+ prefix_text = self._build_dialog_summary(old_msgs, SUMMARY_BUDGET)
349
+
350
+ # 格式化近期消息
351
+ formatted_lines: List[str] = []
352
+ if prefix_text:
353
+ formatted_lines.append(prefix_text)
354
+ formatted_lines.append("") # 空行分隔
355
+
356
+ for label, content in recent_msgs:
357
+ formatted_lines.append(f"[{label}] {_xml_escape(content)}")
313
358
 
314
359
  dialog_text = "\n".join(formatted_lines)
315
360
 
316
- # 截断到最大字符数
361
+ # Token 预算裁剪:超预算时从最早的消息开始移除
317
362
  if len(dialog_text) > max_chars:
318
- # 从尾部截断,避免丢失最新的对话
319
- dialog_text = dialog_text[-max_chars:]
320
- # 从截断点找到最近的换行符以保持完整性
321
- newline_pos = dialog_text.find("\n")
322
- if newline_pos > 0 and newline_pos < 500:
323
- dialog_text = dialog_text[newline_pos + 1:]
324
- # 添加截断标记
325
- dialog_text = "(... 前面的对话已被截断 ...)\n" + dialog_text
363
+ # 保留摘要前缀,裁剪近期消息部分
364
+ if prefix_text:
365
+ # 先尝试只裁剪近期消息
366
+ recent_budget = max_chars - len(prefix_text) - 100
367
+ if recent_budget < 500:
368
+ # 摘要本身太长,整体裁剪
369
+ dialog_text = dialog_text[-max_chars:]
370
+ else:
371
+ recent_part = "\n".join(formatted_lines[len(formatted_lines) - len(recent_msgs):])
372
+ if len(recent_part) > recent_budget:
373
+ recent_part = self._trim_messages_from_start(recent_part, recent_budget)
374
+ dialog_text = prefix_text + "\n\n" + recent_part
375
+ else:
376
+ dialog_text = self._trim_messages_from_start(dialog_text, max_chars)
377
+ dialog_text = "(... 前面的对话已被裁剪 ...)\n" + dialog_text
326
378
 
327
379
  return f"<resentdialog>\n{dialog_text}\n</resentdialog>"
328
380
 
381
+ def _build_dialog_summary(self, old_msgs: List[tuple], max_chars: int) -> str:
382
+ """
383
+ 将旧消息列表压缩为摘要文本。
384
+
385
+ 策略: 提取每条消息的第一行作为要点,保留关键信息的同时大幅压缩篇幅。
386
+ 如果有 MemoryManager,也可以调用 LLM 生成摘要(未来扩展)。
387
+
388
+ Args:
389
+ old_msgs: (label, content) 元组列表
390
+ max_chars: 摘要最大字符数
391
+
392
+ Returns:
393
+ 摘要文本字符串
394
+ """
395
+ if not old_msgs:
396
+ return ""
397
+
398
+ summary_parts: List[str] = ["[历史对话摘要]"]
399
+ for label, content in old_msgs:
400
+ # 提取第一行或前100字符作为要点
401
+ first_line = content.split("\n")[0].strip()
402
+ if len(first_line) > 100:
403
+ first_line = first_line[:100] + "..."
404
+ summary_parts.append(f"- [{label}] {first_line}")
405
+
406
+ summary_text = "\n".join(summary_parts)
407
+
408
+ # 摘要本身也要限制长度
409
+ if len(summary_text) > max_chars:
410
+ summary_text = summary_text[:max_chars] + "\n... (更多历史已省略)"
411
+
412
+ return summary_text
413
+
414
+ def _trim_messages_from_start(self, text: str, max_chars: int) -> str:
415
+ """
416
+ 从文本开头裁剪消息,保留尾部(最新消息优先)。
417
+
418
+ 按行(即按消息)为单位裁剪,避免在消息中间截断。
419
+
420
+ Args:
421
+ text: 格式化后的对话文本
422
+ max_chars: 最大保留字符数
423
+
424
+ Returns:
425
+ 裁剪后的文本
426
+ """
427
+ if len(text) <= max_chars:
428
+ return text
429
+
430
+ lines = text.split("\n")
431
+ result_lines: List[str] = []
432
+ total = 0
433
+
434
+ # 从后往前添加行,保留最新的消息
435
+ for line in reversed(lines):
436
+ if total + len(line) + 1 > max_chars:
437
+ break
438
+ result_lines.append(line)
439
+ total += len(line) + 1
440
+
441
+ result_lines.reverse()
442
+ return "\n".join(result_lines)
443
+
329
444
  def _build_user_input(
330
445
  self,
331
446
  user_typed_text: str,
@@ -203,16 +203,22 @@ class DepartmentManager:
203
203
  if not name:
204
204
  return {"ok": False, "message": "部门名称不能为空"}
205
205
 
206
+ # 清理部门名称:移除空格和特殊字符,保留中英文、数字、下划线、连字符
207
+ import re
208
+ clean_name = re.sub(r'[^\w\u4e00-\u9fff-]', '', name)
209
+ if not clean_name:
210
+ return {"ok": False, "message": "部门名称包含无效字符"}
211
+
206
212
  # 安全校验
207
213
  if not self._validate_path(parent):
208
214
  return {"ok": False, "message": "非法的父部门路径"}
209
- if not self._validate_path(name):
215
+ if not self._validate_path(clean_name):
210
216
  return {"ok": False, "message": "非法的部门名称"}
211
- if "/" in name or "\\" in name:
217
+ if "/" in clean_name or "\\" in clean_name:
212
218
  return {"ok": False, "message": "部门名称不能包含 / 或 \\"}
213
219
 
214
- # 构建完整路径
215
- dept_path = f"{parent}/{name}" if parent else name
220
+ # 构建完整路径(使用清理后的名称作为目录名)
221
+ dept_path = f"{parent}/{clean_name}" if parent else clean_name
216
222
 
217
223
  # 检查是否已存在
218
224
  dept_dir = self._dept_dir(dept_path)
@@ -243,13 +249,14 @@ class DepartmentManager:
243
249
  encoding="utf-8",
244
250
  )
245
251
 
246
- # 自动创建群聊
252
+ # 自动创建群聊(部门群不带默认 owner,成员由 assign_agent 添加)
247
253
  chat_group_id = ""
248
254
  if self._group_manager:
249
255
  try:
250
256
  group_name = f"部门: {name}"
251
257
  group = self._group_manager.create_group(
252
258
  name=group_name,
259
+ owner="", # 部门群无默认 owner
253
260
  description=description or f"{name} 部门群聊",
254
261
  avatar_emoji=emoji or "🏢",
255
262
  )
@@ -559,6 +566,20 @@ class DepartmentManager:
559
566
  logger.warning(
560
567
  f"添加 {agent} 到群聊失败 ({chat_group_id}): {e}"
561
568
  )
569
+ # 如果部门群没有 owner,将第一个 agent 设为 owner
570
+ if not meta.get("head") and self._group_manager:
571
+ try:
572
+ group = self._group_manager.get_group(chat_group_id)
573
+ if group and not group.owner:
574
+ group.owner = agent
575
+ self._group_manager.add_member(
576
+ chat_group_id, agent, role="owner"
577
+ )
578
+ meta["head"] = agent
579
+ except Exception as e:
580
+ logger.warning(
581
+ f"设置部门群 owner 失败 ({chat_group_id}): {e}"
582
+ )
562
583
  msg = f"已添加 {len(agents)} 个成员"
563
584
  elif action == "remove":
564
585
  removed = [a for a in agents if a in current_agents]
@@ -638,6 +659,40 @@ class DepartmentManager:
638
659
  logger.info(f"部门 {path} 负责人已{action}: {head or '无'}")
639
660
  return {"ok": True, "meta": meta, "message": f"已{action}负责人"}
640
661
 
662
+ def update_dept_meta(
663
+ self,
664
+ path: str,
665
+ description: str = None,
666
+ head: str = None,
667
+ ) -> Dict[str, Any]:
668
+ """
669
+ 更新部门元数据(描述、负责人等),不修改 dept.md。
670
+
671
+ Args:
672
+ path: 部门路径
673
+ description: 新描述(None=不修改)
674
+ head: 负责人 Agent 路径(None=不修改)
675
+
676
+ Returns:
677
+ {ok, meta, message}
678
+ """
679
+ if not self._validate_path(path):
680
+ return {"ok": False, "message": "非法路径"}
681
+
682
+ meta = self._load_meta(path)
683
+ if not meta:
684
+ return {"ok": False, "message": f"部门不存在: {path}"}
685
+
686
+ if description is not None:
687
+ meta["description"] = description
688
+ if head is not None:
689
+ meta["head"] = head
690
+ meta["updated_at"] = timestamp()
691
+ self._save_meta(path, meta)
692
+
693
+ logger.info(f"部门 {path} 元数据已更新")
694
+ return {"ok": True, "meta": meta, "message": "已更新"}
695
+
641
696
  # ==========================================================================
642
697
  # 部门介绍(dept.md)
643
698
  # ==========================================================================
package/groups/manager.py CHANGED
@@ -323,14 +323,15 @@ class GroupManager:
323
323
  owner=owner,
324
324
  )
325
325
 
326
- # 添加创建者为群主
327
- owner_member = GroupMember(agent_path=owner, role="owner")
328
- group.add_member(owner_member)
326
+ # 添加创建者为群主(仅当 owner 非空时)
327
+ if owner:
328
+ owner_member = GroupMember(agent_path=owner, role="owner")
329
+ group.add_member(owner_member)
329
330
 
330
331
  # 添加额外成员
331
332
  if member_paths:
332
333
  for mp in member_paths:
333
- if mp != owner:
334
+ if mp and mp != owner:
334
335
  group.add_member(GroupMember(agent_path=mp, role="member"))
335
336
 
336
337
  self._groups[group.id] = group
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {