myagent-ai 1.23.37 → 1.23.39

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/agents/base.py CHANGED
@@ -371,6 +371,9 @@ class BaseAgent(ABC):
371
371
  model=request_kwargs.get("model", self.llm.model),
372
372
  reasoning=full_reasoning,
373
373
  )
374
+ except asyncio.CancelledError:
375
+ # [v1.23.38] 传播取消信号,不吞掉
376
+ raise
374
377
  except Exception as e:
375
378
  logger.error(f"LLM 流式调用失败: {e}")
376
379
  return LLMResponse(success=False, error=str(e))
@@ -215,6 +215,8 @@ class MainAgent(BaseAgent):
215
215
  self._exec_event_counter: int = 0
216
216
  # 活跃会话上下文追踪(用于消息注入)
217
217
  self.active_contexts: Dict[str, AgentContext] = {}
218
+ # [v1.23.37] 会话取消标志(由 api_server.handle_chat_stop 设置)
219
+ self._cancelled_sessions: set = set()
218
220
 
219
221
  def init_context_builder(self, memory_manager=None, skill_registry=None, knowledge_base_dir=None, context_window=None):
220
222
  """初始化 Context Builder(在系统启动后调用,注入依赖)"""
@@ -737,6 +739,14 @@ class MainAgent(BaseAgent):
737
739
  logger.info(f"[{task_id}] V2 迭代 {self._iteration_count}/{max_iter}")
738
740
  _emitted_reasoning_this_iter = False # [v1.15.8] 本轮是否已发送过 v2_reasoning 事件(防 TTS 重复)
739
741
 
742
+ # [v1.23.37] 检查是否收到停止信号
743
+ if context.session_id in self._cancelled_sessions:
744
+ logger.info(f"[{task_id}] 收到停止信号,终止执行循环")
745
+ context.working_memory["final_response"] = "⏹️ 任务已被用户停止"
746
+ await self._emit_v2_event("v2_stopped", {"reason": "用户手动停止"}, stream_callback)
747
+ self._cancelled_sessions.discard(context.session_id)
748
+ break
749
+
740
750
  # ── 检查配置热加载广播 ──
741
751
  if self.config_broadcaster:
742
752
  reloaded, reload_type = await self.config_broadcaster.check_and_wait(task_id)
@@ -1559,6 +1569,14 @@ class MainAgent(BaseAgent):
1559
1569
 
1560
1570
  all_tool_outputs = "\n".join(tool_outputs_parts)
1561
1571
 
1572
+ # [v1.23.38] 工具执行完毕后检查取消信号
1573
+ if context.session_id in self._cancelled_sessions:
1574
+ logger.info(f"[{task_id}] 收到停止信号,跳过 LLM 回调")
1575
+ context.working_memory["final_response"] = "⏹️ 任务已被用户停止"
1576
+ await self._emit_v2_event("v2_stopped", {"reason": "用户手动停止"}, stream_callback)
1577
+ self._cancelled_sessions.discard(context.session_id)
1578
+ break
1579
+
1562
1580
  # Step 12: 有工具执行完毕 → 统一回调 LLM(不再根据 finish 标签判断)
1563
1581
  # 所有工具结果已收集到 all_tool_outputs(或任一超时提前收集),直接喂回 LLM 继续执行
1564
1582
  _timeout_info = "(有工具超时,已停止剩余工具)" if _has_timeout else f"({len(tool_outputs_parts)} 个工具结果已收集)"
@@ -1652,8 +1670,12 @@ class MainAgent(BaseAgent):
1652
1670
  """[v1.22.0] V2 工具执行 — 统一分发到 ToolDispatcher"""
1653
1671
  try:
1654
1672
  import json as _json
1673
+ import html as _html
1655
1674
  try:
1656
- params = _json.loads(parms_str) if parms_str else {}
1675
+ # [v1.23.38] 防御性修复: 解码 HTML 实体 (& & 等)
1676
+ # LLM 可能在 XML 输出中使用 & 代替 &,导致 bash 命令失败
1677
+ _clean_parms = _html.unescape(parms_str) if parms_str else ""
1678
+ params = _json.loads(_clean_parms) if _clean_parms else {}
1657
1679
  except (_json.JSONDecodeError, TypeError):
1658
1680
  params = {"raw_input": parms_str}
1659
1681
 
@@ -48,6 +48,7 @@ Fault-tolerance features:
48
48
 
49
49
  from __future__ import annotations
50
50
 
51
+ import html
51
52
  import re
52
53
  from dataclasses import dataclass, field
53
54
  from typing import Any, Dict, List
@@ -227,7 +228,7 @@ def _extract_tag_content(text: str, tag_name: str, stop_tags: List[str] | None =
227
228
  re.DOTALL | re.IGNORECASE,
228
229
  )
229
230
  if m:
230
- return m.group(1)
231
+ return html.unescape(m.group(1))
231
232
 
232
233
  # Conservative mode: only extract properly closed tags, skip all fallbacks
233
234
  if conservative:
@@ -251,7 +252,7 @@ def _extract_tag_content(text: str, tag_name: str, stop_tags: List[str] | None =
251
252
  re.DOTALL | re.IGNORECASE,
252
253
  )
253
254
  if m:
254
- return m.group(1)
255
+ return html.unescape(m.group(1))
255
256
 
256
257
  # Strategy 3: Self-closing <tag/> or <tag />
257
258
  m = re.search(rf"<{tag_esc}[^>]*/\s*>", text, re.IGNORECASE)
@@ -268,7 +269,7 @@ def _extract_tag_content(text: str, tag_name: str, stop_tags: List[str] | None =
268
269
  content = m.group(1).strip()
269
270
  # Only return if there's actual content (not just whitespace)
270
271
  if content:
271
- return content
272
+ return html.unescape(content)
272
273
 
273
274
  return ""
274
275
 
@@ -300,7 +301,7 @@ def _extract_all_tag_blocks(
300
301
  re.DOTALL | re.IGNORECASE,
301
302
  )
302
303
  if properly_closed:
303
- return properly_closed
304
+ return [html.unescape(b) for b in properly_closed]
304
305
 
305
306
  # Conservative mode: only extract properly closed blocks
306
307
  if conservative:
@@ -326,7 +327,7 @@ def _extract_all_tag_blocks(
326
327
  else:
327
328
  content_end = len(text)
328
329
 
329
- blocks.append(text[content_start:content_end])
330
+ blocks.append(html.unescape(text[content_start:content_end]))
330
331
 
331
332
  return blocks
332
333
 
@@ -221,6 +221,10 @@ class ExecutionEngine:
221
221
  self.work_dir = work_dir or os.getcwd()
222
222
  self._permission_checker = permission_checker # callable(perm_name) -> bool
223
223
  self._agent_name = agent_name
224
+ # [v1.23.37] 当前正在运行的子进程引用,用于 cancel_current() 终止
225
+ self._current_process: Optional[asyncio.subprocess.Process] = None
226
+ # [v1.23.38] 取消标志,用于在长等待中响应停止请求
227
+ self._cancelled = False
224
228
 
225
229
  # 安全: 合并正则模式 + 字符串黑名单
226
230
  self._blocked = set(self.DANGEROUS_COMMANDS)
@@ -506,6 +510,9 @@ class ExecutionEngine:
506
510
  work_dir = work_dir or self.work_dir
507
511
  metadata = metadata or {}
508
512
 
513
+ # [v1.23.38] 每次执行前重置取消标志
514
+ self._cancelled = False
515
+
509
516
  # 权限检查: execution 权限
510
517
  if not self._check_permission("execution"):
511
518
  return ExecResult(
@@ -606,6 +613,7 @@ class ExecutionEngine:
606
613
  cwd=work_dir,
607
614
  env=self._get_env(env),
608
615
  )
616
+ self._current_process = process # [v1.23.37] 保存引用
609
617
 
610
618
  try:
611
619
  # 使用流式读取代替 communicate(),确保超时后能保留已产生的输出
@@ -620,7 +628,10 @@ class ExecutionEngine:
620
628
  if not chunk:
621
629
  break
622
630
  chunks.append(chunk)
623
- except (asyncio.CancelledError, Exception):
631
+ except asyncio.CancelledError:
632
+ # [v1.23.38] 传播取消信号,不再吞掉
633
+ raise
634
+ except Exception:
624
635
  pass
625
636
 
626
637
  # 同时启动 stdout 和 stderr 的读取任务
@@ -633,28 +644,63 @@ class ExecutionEngine:
633
644
 
634
645
  timed_out = False
635
646
  try:
636
- # 等待进程结束,同时流式收集输出
637
- done, pending = await asyncio.wait(
638
- [
639
- asyncio.ensure_future(process.wait()),
640
- stdout_task,
641
- stderr_task,
642
- ],
643
- timeout=timeout,
644
- )
645
- if pending:
646
- # 超时发生
647
- timed_out = True
648
- for t in pending:
649
- t.cancel()
650
- try:
651
- process.kill()
652
- except ProcessLookupError:
653
- pass
654
- # 等待取消完成,获取已缓冲的数据
655
- await asyncio.gather(*pending, return_exceptions=True)
656
-
657
- except Exception:
647
+ # [v1.23.38] 使用短间隔轮询,确保取消信号能及时响应
648
+ wait_timeout = min(timeout, 3.0) # 每3秒检查一次取消标志
649
+ remaining = timeout
650
+ while True:
651
+ if self._cancelled:
652
+ # 收到停止信号,终止进程
653
+ try:
654
+ process.kill()
655
+ except ProcessLookupError:
656
+ pass
657
+ for t in [stdout_task, stderr_task]:
658
+ if not t.done():
659
+ t.cancel()
660
+ await asyncio.gather(
661
+ stdout_task, stderr_task,
662
+ return_exceptions=True,
663
+ )
664
+ elapsed = asyncio.get_event_loop().time() - start_time
665
+ stdout_str = b"".join(stdout_chunks).decode("utf-8", errors="replace")
666
+ stderr_str = b"".join(stderr_chunks).decode("utf-8", errors="replace")
667
+ logger.info(f"[executor] 执行被用户停止 (elapsed={elapsed:.1f}s)")
668
+ return ExecResult(
669
+ success=False,
670
+ output=stdout_str,
671
+ error="执行被用户停止",
672
+ execution_time=elapsed,
673
+ metadata={"cancelled": True},
674
+ )
675
+ done, pending = await asyncio.wait(
676
+ [
677
+ asyncio.ensure_future(process.wait()),
678
+ stdout_task,
679
+ stderr_task,
680
+ ],
681
+ timeout=wait_timeout,
682
+ )
683
+ if process.returncode is not None:
684
+ # 进程已结束
685
+ if pending:
686
+ for t in pending:
687
+ t.cancel()
688
+ await asyncio.gather(*pending, return_exceptions=True)
689
+ break
690
+ remaining -= wait_timeout
691
+ if remaining <= 0:
692
+ # 超时
693
+ timed_out = True
694
+ for t in pending:
695
+ t.cancel()
696
+ try:
697
+ process.kill()
698
+ except ProcessLookupError:
699
+ pass
700
+ await asyncio.gather(*pending, return_exceptions=True)
701
+ break
702
+ except asyncio.CancelledError:
703
+ # [v1.23.38] asyncio.Task 被取消时传播
658
704
  timed_out = True
659
705
  try:
660
706
  process.kill()
@@ -667,6 +713,7 @@ class ExecutionEngine:
667
713
  stdout_task, stderr_task,
668
714
  return_exceptions=True,
669
715
  )
716
+ raise
670
717
 
671
718
  elapsed = asyncio.get_event_loop().time() - start_time
672
719
  stdout_str = b"".join(stdout_chunks).decode("utf-8", errors="replace")
@@ -747,6 +794,7 @@ class ExecutionEngine:
747
794
  cwd=work_dir,
748
795
  env=self._get_env(env),
749
796
  )
797
+ self._current_process = process # [v1.23.37] 保存引用
750
798
 
751
799
  # 使用流式读取代替 communicate(),确保超时后能保留已产生的输出
752
800
  stdout_chunks = []
@@ -948,6 +996,7 @@ class ExecutionEngine:
948
996
  cwd=work_dir,
949
997
  env=self._get_env(env),
950
998
  )
999
+ self._current_process = process # [v1.23.37] 保存引用
951
1000
  try:
952
1001
  stdout, stderr = await asyncio.wait_for(
953
1002
  process.communicate(), timeout=timeout
@@ -989,6 +1038,27 @@ class ExecutionEngine:
989
1038
  metadata={"mode": "sandbox"},
990
1039
  )
991
1040
 
1041
+ # [v1.23.37] ── 取消当前执行 ──
1042
+
1043
+ def cancel_current(self):
1044
+ """终止当前正在运行的子进程(由 /api/chat/stop 调用)"""
1045
+ self._cancelled = True # [v1.23.38] 设置取消标志
1046
+ proc = self._current_process
1047
+ if proc is None:
1048
+ return False
1049
+ try:
1050
+ if proc.returncode is None: # 进程仍在运行
1051
+ proc.kill()
1052
+ logger.info(f"[executor] 已终止子进程 PID={proc.pid}")
1053
+ self._current_process = None
1054
+ return True
1055
+ except ProcessLookupError:
1056
+ pass # 进程已结束
1057
+ except Exception as e:
1058
+ logger.debug(f"[executor] 终止子进程失败: {e}")
1059
+ self._current_process = None
1060
+ return False
1061
+
992
1062
  def set_execution_mode(self, mode: str) -> bool:
993
1063
  """切换执行模式。返回是否切换成功。"""
994
1064
  if mode not in ("local", "sandbox"):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.23.37",
3
+ "version": "1.23.39",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
@@ -43,4 +43,4 @@
43
43
  "python": ">=3.10",
44
44
  "node": ">=18"
45
45
  }
46
- }
46
+ }
package/web/api_server.py CHANGED
@@ -406,6 +406,8 @@ class ApiServer:
406
406
  r.add_post("/api/chat", self.handle_chat)
407
407
  r.add_post("/api/chat/stream", self.handle_chat_stream)
408
408
  r.add_post("/api/chat/inject", self.handle_chat_inject)
409
+ # [v1.23.37] 停止执行
410
+ r.add_post("/api/chat/stop", self.handle_chat_stop)
409
411
  r.add_post("/api/voice-optimize", self.handle_voice_optimize)
410
412
  r.add_post("/api/voice-stt", self.handle_voice_stt)
411
413
  r.add_get("/chat", self.handle_chat_page)
@@ -1911,6 +1913,10 @@ window.addEventListener('beforeunload', function() {{
1911
1913
  # {session_id: {running: bool, started_at: float, result: str, done: bool, error: str}}
1912
1914
  _running_sessions: Dict[str, Dict] = {}
1913
1915
  _running_sessions_ttl = 300 # 过期时间(秒),用于自动清理已完成会话
1916
+ # [v1.23.37] 存储后台 asyncio.Task 引用,用于取消执行
1917
+ _running_tasks: Dict[str, asyncio.Task] = {}
1918
+ # [v1.23.37] 会话取消标志,供 agent 执行循环检查
1919
+ _session_cancelled: Dict[str, bool] = {}
1914
1920
 
1915
1921
  async def _get_session_lock(self, session_id: str) -> asyncio.Lock:
1916
1922
  """获取或创建会话级锁(每个 session_id 一个锁,防并发覆盖 MainAgent 共享状态)"""
@@ -2169,6 +2175,15 @@ window.addEventListener('beforeunload', function() {{
2169
2175
  result_store["done"] = True
2170
2176
  await safe_write({"type": "done", "exec_events": exec_events_q, "session_id": session_id})
2171
2177
 
2178
+ except asyncio.CancelledError:
2179
+ # [v1.23.38] 任务被取消(用户点击停止),清理资源
2180
+ logger.info(f"[{session_id}] 后台流式任务被取消")
2181
+ result_store["done"] = True
2182
+ result_store["cancelled"] = True
2183
+ try:
2184
+ await safe_write({"type": "stopped", "session_id": session_id})
2185
+ except Exception:
2186
+ pass
2172
2187
  except Exception as e:
2173
2188
  logger.error(f"[{session_id}] 后台流式任务异常: {e}", exc_info=True)
2174
2189
  result_store["error"] = str(e)
@@ -2210,10 +2225,15 @@ window.addEventListener('beforeunload', function() {{
2210
2225
  session_lock.release()
2211
2226
  except RuntimeError:
2212
2227
  pass
2228
+ # [v1.23.37] 清理任务引用和取消标志
2229
+ self._running_tasks.pop(session_id, None)
2230
+ self._session_cancelled.pop(session_id, None)
2213
2231
 
2214
2232
  # ── 启动后台任务(不等它完成,前端断开也不影响) ──
2215
2233
  logger.info(f"[{session_id}] 准备创建后台任务")
2216
2234
  bg_task = asyncio.create_task(_run_stream_task())
2235
+ self._running_tasks[session_id] = bg_task # [v1.23.37] 保存引用用于取消
2236
+ self._session_cancelled[session_id] = False # [v1.23.37] 初始化取消标志
2217
2237
  logger.info(f"[{session_id}] 后台任务已创建: {bg_task}")
2218
2238
 
2219
2239
  # ── SSE 事件循环:实时转发后台任务的事件到客户端 ──
@@ -2240,6 +2260,56 @@ window.addEventListener('beforeunload', function() {{
2240
2260
 
2241
2261
  return response
2242
2262
 
2263
+ # [v1.23.37] ── 停止执行 ──
2264
+
2265
+ async def handle_chat_stop(self, request):
2266
+ """POST /api/chat/stop - 停止正在执行的任务
2267
+
2268
+ 停止链:前端 abort → 调用此 API → 设置取消标志 + cancel task
2269
+ → agent 循环检测到取消标志 → 退出循环 → 任务结束
2270
+ """
2271
+ try:
2272
+ data = await request.json()
2273
+ except Exception:
2274
+ return web.json_response({"error": "invalid JSON"}, status=400)
2275
+
2276
+ session_id = data.get("session_id", "")
2277
+ if not session_id:
2278
+ return web.json_response({"ok": False, "error": "missing session_id"})
2279
+
2280
+ logger.info(f"[{session_id}] 收到停止执行请求")
2281
+
2282
+ # 1. 设置取消标志(agent 循环会检查)
2283
+ self._session_cancelled[session_id] = True
2284
+
2285
+ # 1.5 设置 MainAgent 上的取消标志(agent 循环会检查 _cancelled_sessions)
2286
+ if self.core.main_agent:
2287
+ self.core.main_agent._cancelled_sessions.add(session_id)
2288
+
2289
+ # 2. 尝试取消 asyncio.Task
2290
+ task = self._running_tasks.get(session_id)
2291
+ cancelled = False
2292
+ if task and not task.done():
2293
+ task.cancel()
2294
+ cancelled = True
2295
+ logger.info(f"[{session_id}] 已发送 task.cancel()")
2296
+
2297
+ # 3. 更新会话状态
2298
+ sess = self._running_sessions.get(session_id)
2299
+ if sess and sess.get("running"):
2300
+ sess["running"] = False
2301
+ sess["done"] = True
2302
+ sess["cancelled"] = True
2303
+
2304
+ # 4. 尝试终止正在运行的子进程(如果 executor 正在执行命令)
2305
+ try:
2306
+ if hasattr(self.core, 'executor') and self.core.executor:
2307
+ self.core.executor.cancel_current()
2308
+ except Exception as e:
2309
+ logger.debug(f"[{session_id}] executor 取消失败: {e}")
2310
+
2311
+ return web.json_response({"ok": True, "cancelled": cancelled})
2312
+
2243
2313
  async def handle_chat_inject(self, request):
2244
2314
  """POST /api/chat/inject - 注入消息到正在执行的任务,或进入排队序列"""
2245
2315
  try:
@@ -2392,6 +2392,16 @@ async function sendMessage(opts) {
2392
2392
 
2393
2393
  // ── Stop Generation ──
2394
2394
  function stopGenerating() {
2395
+ // [v1.23.37] 告知后端停止执行(kill subprocess + 设置取消标志)
2396
+ var sid = state.activeSessionId || '';
2397
+ if (sid) {
2398
+ fetch('/api/chat/stop', {
2399
+ method: 'POST',
2400
+ headers: { 'Content-Type': 'application/json' },
2401
+ body: JSON.stringify({ session_id: sid }),
2402
+ }).catch(function() {});
2403
+ }
2404
+ // 中断前端 SSE 连接
2395
2405
  if (state.abortController) {
2396
2406
  state.abortController.abort();
2397
2407
  }