diff --git a/CUSTOM_MODIFICATIONS.md b/CUSTOM_MODIFICATIONS.md new file mode 100644 index 0000000..0121f7d --- /dev/null +++ b/CUSTOM_MODIFICATIONS.md @@ -0,0 +1,307 @@ +# Clawith 自定义修改记录 + +记录 Clawith 升级时需要保留的修改,避免被覆盖。 + +--- + +## 1. httpx 禁用系统代理 + +**文件**: `backend/app/services/llm_client.py` + +**位置**: 4 个客户端类的 `_get_client()` 方法 + +- Line 215: `OpenAICompatibleClient._get_client()` +- Line 546: `GeminiClient._get_client()` +- Line 852: `AnthropicClient._get_client()` +- Line 1343: `OllamaClient._get_client()` + +**问题**: httpx 默认读取系统代理设置,导致请求被拦截,LLM 调用返回 502。 + +**修改**: 在创建 `httpx.AsyncClient` 时添加 `trust_env=False` + +```python +async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) + return self._client +``` + +> 搜索 `trust_env=False` 确认所有 4 处都已修改 + +--- + +## 2. Windows subprocess 支持 (Python 3.12) + +**文件**: `backend/app/services/agent_tools.py` + +### 2.1 模块级事件循环策略 + +**位置**: 文件开头,`from loguru import logger` 之后 + +**问题**: Python 3.12 在 Windows 上默认 `SelectorEventLoop` 不支持 subprocess,导致 `asyncio.create_subprocess_exec` 抛出 `NotImplementedError`。 + +**修改**: + +```python +from loguru import logger + +import sys as _sys +if _sys.platform == "win32": + import asyncio as _asyncio + _asyncio.set_event_loop_policy(_asyncio.WindowsProactorEventLoopPolicy()) +``` + +### 2.2 subprocess 编码修复 + +**位置**: `_execute_code` 函数内,`await asyncio.wait_for(proc.communicate(), timeout=timeout)` 之后 + +**问题**: Windows subprocess 默认输出用 GBK 编码,但代码用 UTF-8 解码导致乱码。 + +**修改**: + +```python +# 修改前 +stdout_str = stdout.decode("utf-8", errors="replace")[:10000] +stderr_str = stderr.decode("utf-8", errors="replace")[:5000] + +# 修改后 +if _sys.platform == "win32": + def _try_decode_win(data): + if not data: + return "" + if len(data) >= 2 and data[:2] == b"\xff\xfe": + return data[2:].decode("utf-16-le", errors="replace") + null_count = sum(1 for i in range(1, min(len(data), 1000), 2) if data[i] == 0) + if null_count / max(len(data) // 2, 1) > 0.3: + return data.decode("utf-16-le", errors="replace") + return data.decode("gbk", errors="replace") + stdout_str = _try_decode_win(stdout)[:10000] + stderr_str = _try_decode_win(stderr)[:5000] +else: + stdout_str = stdout.decode("utf-8", errors="replace")[:10000] + stderr_str = stderr.decode("utf-8", errors="replace")[:5000] +``` + +> 2026-03-21 更新:加了 UTF-16LE 检测,解决 PowerShell 输出的 UTF-16LE 编码问题。 + +### 2.3 bash 命令改用 PowerShell + +**位置**: `_execute_code` 函数内,`if language == "bash"` 分支 + +**问题**: Windows 没有 `bash` 命令。Git Bash 会检测 WSL 环境,但 WSL 没有安装 Linux 分发版时会导致 `agent-browser` 等工具出错。 + +**修改**: + +```python +# 修改前 +elif language == "bash": + ext = ".sh" + import shutil as _shutil + if _shutil.which("bash"): + cmd_prefix = ["bash"] + elif _shutil.which("cmd"): + cmd_prefix = ["cmd", "/c"] + else: + cmd_prefix = ["powershell", "-Command"] + +# 修改后 +elif language == "bash": + ext = ".bat" + cmd_prefix = ["powershell", "-Command"] +``` + +> 2026-03-21 更新:删除了 bash/cmd 自动检测,直接固定用 PowerShell,避免 Git Bash 检测 WSL 导致的问题。 + +--- + +## 3. feishu tool_call 历史记录修复 + +**文件**: `backend/app/api/feishu.py` + +**位置**: `_call_agent_llm` 函数内,历史记录构建部分(约 line 992) + +**问题**: feishu 路由的消息在加载历史时跳过了 `role='tool_call'` 的消息,导致多轮工具调用对话中工具调用信息丢失,LLM 报错 "No tool output found for function call"。 + +**修改**: 将简单的列表推导式替换为循环,正确处理 `tool_call` 角色: + +```python +# 修改前 +_history = [{"role": m.role, "content": m.content} for m in reversed(_hist_r.scalars().all())] + +# 修改后 +_hist_list = list(reversed(_hist_r.scalars().all())) +_history = [] +for m in _hist_list: + if m.role == "tool_call": + import json as _j_tc + try: + tc_data = _j_tc.loads(m.content) + tc_name = tc_data.get("name", "unknown") + tc_args = tc_data.get("args", {}) + tc_result = tc_data.get("result", "") + tc_id = f"call_{m.id}" + _history.append({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": tc_id, + "type": "function", + "function": {"name": tc_name, "arguments": _j_tc.dumps(tc_args, ensure_ascii=False)}, + }], + }) + _history.append({ + "role": "tool", + "tool_call_id": tc_id, + "content": str(tc_result)[:500], + }) + except Exception: + continue + else: + entry = {"role": m.role, "content": m.content} + if hasattr(m, 'thinking') and m.thinking: + entry["thinking"] = m.thinking + _history.append(entry) +``` + +--- + +## 4. emoji 日志编码修复 + +**文件**: `backend/app/main.py` + +**位置**: `migrate_enterprise_info()` 函数内的 print 语句 + +**问题**: Windows GBK 终端无法打印 emoji 字符,导致启动时异常退出。 + +**修改**: 将 emoji 替换为 ASCII 字符: + +```python +# 修改前 +print(f"[startup] ✅ Migrated enterprise_info → enterprise_info_{_tenant.id}", flush=True) +print(f"[startup] ℹ️ enterprise_info_{_tenant.id} already exists, skipping migration", flush=True) + +# 修改后 +print(f"[startup] [OK] Migrated enterprise_info -> enterprise_info_{_tenant.id}", flush=True) +print(f"[startup] [i] enterprise_info_{_tenant.id} already exists, skipping migration", flush=True) +``` + +--- + +## 5. 启动脚本路径修复 + +**文件**: `links.bat`、`clawith.bat` + +**问题**: Windows 上 venv Scripts 路径为 `.venv\Scripts\` 而非 `.venv/bin/`。 + +**修改** (`links.bat`): +```bash +# 修改前 +.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port $BACKEND_PORT + +# 修改后 +.venv/Scripts/uvicorn app.main:app --host 0.0.0.0 --port $BACKEND_PORT +``` + +**修改** (`clawith.bat`): +```bat +REM 修改前 +uv run uvicorn app.main:app --reload --port 8008 + +REM 修改后 +.venv\Scripts\uvicorn app.main:app --port 8008 +``` + +> 注意:移除了 `--reload` 参数,因为 watchfiles 子进程可能与 Windows 事件循环冲突。 + +--- + +## 6. 禁用 Agent Seeder + +**文件**: `backend/app/main.py` + +**位置**: `startup()` 函数(约 line 181-184) + +**问题**: 每次重启服务都会运行 `seed_default_agents()`,覆盖用户自定义的 Agent。 + +**修改**: 注释掉相关调用: + +```python +# await seed_default_agents() +# 如果以上命令报错,可能是重复的 agent name,执行以下命令解决: +# truncate_table("agents") +``` + +--- + +## 7. MCP 工具在 Agent Tools 分配页面不显示 + +**文件**: `backend/app/api/tools.py` + +**位置**: `GET /api/tools/agents/{agent_id}/with-config` 端点(约 line 188 和 line 400) + +**问题**: MCP 工具只有在 Agent 已有分配记录时才显示,用户从未分配过所以看不到,形成"先有鸡还是先有蛋"的问题。 + +**修改**: 删除两处 `if t.type == "mcp" and not at: continue` 检查 + +```python +# 修改前 +tid = str(t.id) +at = assignments.get(tid) +# MCP tools only show for agents that have an explicit assignment +if t.type == "mcp" and not at: + continue +enabled = at.enabled if at else t.is_default + +# 修改后 +tid = str(t.id) +at = assignments.get(tid) +enabled = at.enabled if at else t.is_default +``` + +两处均修改(line 188 和 line 400)。 + +两处均修改(line 188 和 line 400)。 + +## 8. 前端聊天输入框卡顿优化 + +**文件**: `frontend/src/pages/AgentDetail.tsx` + +**问题**: 输入框打字延迟——每次按键触发 `setChatInput` 更新 state,导致整个父组件(4400+行)re-render。 + +**修改**: + +1. 删除全部 `refetchInterval`(4处):避免不必要的定时数据刷新触发 re-render。 + +2. 输入框改为 uncontrolled 模式,完全绕过 React state: + - `ChatInput` 组件去掉 `value/onChange`,使用原生 `` + - `sendChatMsg` 直接从 `chatInputRef.current.value` 读取输入值 + - 发送后直接清空 DOM:`if (chatInputRef.current) chatInputRef.current.value = ''` + - `sendChatMsg` 加上 `useCallback` + +```tsx +// ChatInput 组件改为 uncontrolled +const ChatInput = React.memo(({ onKeyDown, onPaste, placeholder, disabled, autoFocus, inputRef }) => ( + +)); + +// sendChatMsg 读 DOM 而非 state +const _inputEl = chatInputRef.current; +if (!_inputEl) return; +const _inputVal = _inputEl.value.trim(); +if (!_inputVal && attachedFiles.length === 0) return; +const userMsg = _inputVal; + +// 发送后清空 DOM +if (chatInputRef.current) chatInputRef.current.value = ''; + +// 发送按钮禁用条件修复 + +``` + +## 9. 其他(已在 v1.7.1 合并) + +以下修复在 v1.7.1 中已合并,无需手动修改: + +- `write_text()` 添加 `encoding="utf-8"` 解决 Windows GBK 编码问题 +- Skill 创建时初始化 `files = []` 避免异步懒加载错误 diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index 80819a9..2647b1d 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -20,6 +20,7 @@ # ─── OAuth ────────────────────────────────────────────── + @router.post("/auth/feishu/callback", response_model=TokenResponse) async def feishu_oauth_callback(code: str, db: AsyncSession = Depends(get_db)): """Handle Feishu OAuth callback — exchange code for user session.""" @@ -45,6 +46,7 @@ async def bind_feishu_account( # ─── Channel Config (per-agent Feishu bot) ────────────── + @router.post("/agents/{agent_id}/channel", response_model=ChannelConfigOut, status_code=status.HTTP_201_CREATED) async def configure_channel( agent_id: uuid.UUID, @@ -58,10 +60,12 @@ async def configure_channel( raise HTTPException(status_code=403, detail="Only creator can configure channel") # Check existing - result = await db.execute(select(ChannelConfig).where( - ChannelConfig.agent_id == agent_id, - ChannelConfig.channel_type == "feishu", - )) + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == "feishu", + ) + ) existing = result.scalar_one_or_none() if existing: existing.app_id = data.app_id @@ -71,16 +75,17 @@ async def configure_channel( existing.extra_config = data.extra_config or {} existing.is_configured = True await db.flush() - + # Start/Stop WS client in background from app.services.feishu_ws import feishu_ws_manager import asyncio + mode = existing.extra_config.get("connection_mode", "webhook") if mode == "websocket": asyncio.create_task(feishu_ws_manager.start_client(agent_id, existing.app_id, existing.app_secret)) else: asyncio.create_task(feishu_ws_manager.stop_client(agent_id)) - + return ChannelConfigOut.model_validate(existing) config = ChannelConfig( @@ -99,6 +104,7 @@ async def configure_channel( # Start WS client in background from app.services.feishu_ws import feishu_ws_manager import asyncio + mode = config.extra_config.get("connection_mode", "webhook") if mode == "websocket": asyncio.create_task(feishu_ws_manager.start_client(agent_id, config.app_id, config.app_secret)) @@ -114,10 +120,12 @@ async def get_channel_config( ): """Get Feishu channel configuration for an agent.""" await check_agent_access(db, current_user, agent_id) - result = await db.execute(select(ChannelConfig).where( - ChannelConfig.agent_id == agent_id, - ChannelConfig.channel_type == "feishu", - )) + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == "feishu", + ) + ) config = result.scalar_one_or_none() if not config: raise HTTPException(status_code=404, detail="Channel not configured") @@ -129,6 +137,7 @@ async def get_webhook_url(agent_id: uuid.UUID, request: Request, db: AsyncSessio """Get the webhook URL for this agent's Feishu bot.""" import os from app.models.system_settings import SystemSetting + # Priority: system_settings > env var > request.base_url public_base = "" result = await db.execute(select(SystemSetting).where(SystemSetting.key == "platform")) @@ -152,17 +161,18 @@ async def delete_channel_config( agent, _access = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): raise HTTPException(status_code=403, detail="Only creator can remove channel") - result = await db.execute(select(ChannelConfig).where( - ChannelConfig.agent_id == agent_id, - ChannelConfig.channel_type == "feishu", - )) + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == "feishu", + ) + ) config = result.scalar_one_or_none() if not config: raise HTTPException(status_code=404, detail="Channel not configured") await db.delete(config) - # ─── Feishu Event Webhook ─────────────────────────────── # Simple in-memory dedup to avoid processing retried events @@ -177,7 +187,7 @@ async def feishu_event_webhook( ): """Handle Feishu event callback for a specific agent's bot.""" body = await request.json() - + # Handle verification challenge if "challenge" in body: return {"challenge": body["challenge"]} @@ -188,7 +198,10 @@ async def feishu_event_webhook( async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession): """Core logic to process feishu events from both webhook and WS client.""" import json as _json - logger.info(f"[Feishu] Event processing for {agent_id}: event_type={body.get('header', {}).get('event_type', 'N/A')}") + + logger.info( + f"[Feishu] Event processing for {agent_id}: event_type={body.get('header', {}).get('event_type', 'N/A')}" + ) # Deduplicate — Feishu retries on slow responses # Only mark as processed AFTER successful handling so retries work on crash @@ -232,6 +245,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # ── Normalize post (rich text) → extract text + schedule image downloads ── if msg_type == "post": import json as _json_post + _post_body = _json_post.loads(message.get("content", "{}")) # Feishu post content: {"title": "...", "content": [[{"tag":"text","text":"..."},...],...]} # The content may be nested under a locale key like "zh_cn" @@ -265,9 +279,11 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession _image_markers = [] if _post_image_keys: import base64 as _b64 + _msg_id = message.get("message_id", "") from pathlib import Path as _PostPath from app.config import get_settings as _post_gs + _post_settings = _post_gs() _upload_dir = _PostPath(_post_settings.AGENT_DATA_DIR) / str(agent_id) / "workspace" / "uploads" _upload_dir.mkdir(parents=True, exist_ok=True) @@ -298,25 +314,26 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession if msg_type in ("file", "image"): import asyncio as _asyncio + _asyncio.create_task(_handle_feishu_file(db, agent_id, config, message, sender_open_id, chat_type, chat_id)) return {"code": 0, "msg": "ok"} if msg_type == "text": import json import re + content = json.loads(message.get("content", "{}")) user_text = content.get("text", "") # Strip @mention tags (e.g. @_user_1) from group messages - user_text = re.sub(r'@_user_\d+', '', user_text).strip() + user_text = re.sub(r"@_user_\d+", "", user_text).strip() if not user_text: return {"code": 0, "msg": "empty message after stripping mentions"} # Detect task creation intent task_match = re.search( - r'(?:创建|新建|添加|建一个|帮我建)(?:一个)?(?:任务|待办|todo)[,,::\s]*(.+)', - user_text, re.IGNORECASE + r"(?:创建|新建|添加|建一个|帮我建)(?:一个)?(?:任务|待办|todo)[,,::\s]*(.+)", user_text, re.IGNORECASE ) # Determine conversation_id for history isolation @@ -330,6 +347,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession from app.models.audit import ChatMessage from app.models.agent import Agent as AgentModel from app.services.channel_session import find_or_create_channel_session + agent_r = await db.execute(select(AgentModel).where(AgentModel.id == agent_id)) agent_obj = agent_r.scalar_one_or_none() creator_id = agent_obj.creator_id if agent_obj else agent_id @@ -337,9 +355,10 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # Pre-resolve session so history lookup uses the UUID (session created later if new) _pre_sess_r = await db.execute( - select(__import__('app.models.chat_session', fromlist=['ChatSession']).ChatSession).where( - __import__('app.models.chat_session', fromlist=['ChatSession']).ChatSession.agent_id == agent_id, - __import__('app.models.chat_session', fromlist=['ChatSession']).ChatSession.external_conv_id == conv_id, + select(__import__("app.models.chat_session", fromlist=["ChatSession"]).ChatSession).where( + __import__("app.models.chat_session", fromlist=["ChatSession"]).ChatSession.agent_id == agent_id, + __import__("app.models.chat_session", fromlist=["ChatSession"]).ChatSession.external_conv_id + == conv_id, ) ) _pre_sess = _pre_sess_r.scalar_one_or_none() @@ -375,7 +394,9 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession headers={"Authorization": f"Bearer {_app_token}"}, ) _user_data = _user_resp.json() - logger.info(f"[Feishu] Sender resolve: code={_user_data.get('code')}, msg={_user_data.get('msg', '')}") + logger.info( + f"[Feishu] Sender resolve: code={_user_data.get('code')}, msg={_user_data.get('msg', '')}" + ) if _user_data.get("code") == 0: _user_info = _user_data.get("data", {}).get("user", {}) sender_name = _user_info.get("name", "") @@ -386,6 +407,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession if sender_name and sender_open_id: try: import pathlib as _pl, json as _cj, time as _ct + _safe_id = str(agent_id).replace("..", "").replace("/", "") _cache = _pl.Path(f"/data/workspaces/{_safe_id}/feishu_contacts_cache.json") _cache.parent.mkdir(parents=True, exist_ok=True) @@ -407,11 +429,14 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession "email": sender_email, "user_id": sender_user_id_feishu, } - _cache.write_text(_cj.dumps( - {"ts": _ct.time(), "users": list(_users.values())}, - ensure_ascii=False, - )) + _cache.write_text( + _cj.dumps( + {"ts": _ct.time(), "users": list(_users.values())}, + ensure_ascii=False, + ) + ) import os as _os + _os.chmod(str(_cache), 0o600) except Exception as _ce: logger.error(f"[Feishu] Cache write failed: {_ce}") @@ -420,9 +445,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # Look up platform user by feishu_user_id or feishu_open_id if sender_user_id_feishu: - u_result = await db.execute( - select(User).where(User.feishu_user_id == sender_user_id_feishu) - ) + u_result = await db.execute(select(User).where(User.feishu_user_id == sender_user_id_feishu)) found_user = u_result.scalar_one_or_none() if found_user: platform_user_id = found_user.id @@ -430,9 +453,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession if platform_user_id == creator_id and sender_open_id: # Try by feishu_open_id (if same app ID was used for SSO) - u_result2 = await db.execute( - select(User).where(User.feishu_open_id == sender_open_id) - ) + u_result2 = await db.execute(select(User).where(User.feishu_open_id == sender_open_id)) found_user2 = u_result2.scalar_one_or_none() if found_user2: platform_user_id = found_user2.id @@ -441,6 +462,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # Auto-create user if not found and we have sender info if platform_user_id == creator_id and sender_name: from app.core.security import hash_password + new_username = f"feishu_{sender_user_id_feishu or sender_open_id[:16]}" new_user = User( username=new_username, @@ -459,6 +481,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # ── Find-or-create a ChatSession via external_conv_id (DB-based, no cache needed) ── from datetime import datetime as _dt, timezone as _tz + _sess = await find_or_create_channel_session( db=db, agent_id=agent_id, @@ -470,11 +493,18 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession session_conv_id = str(_sess.id) # Save user message - db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="user", content=user_text, conversation_id=session_conv_id)) + db.add( + ChatMessage( + agent_id=agent_id, + user_id=platform_user_id, + role="user", + content=user_text, + conversation_id=session_conv_id, + ) + ) _sess.last_message_at = _dt.now(_tz.utc) await db.commit() - # Prepend sender identity so the agent knows who is talking llm_user_text = user_text if sender_name: @@ -489,6 +519,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession import time as _time import pathlib as _pl from app.config import get_settings as _gs + _upload_dir = _pl.Path(_gs().AGENT_DATA_DIR) / str(agent_id) / "workspace" / "uploads" _recent_file_path = None if _upload_dir.exists() and "uploads/" not in user_text and "workspace/" not in user_text: @@ -507,10 +538,9 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # AGENT_DATA_DIR/{agent_id}/, so the correct relative path is workspace/uploads/ _ws_rel_path = f"workspace/{_recent_file_path}" llm_user_text = ( - llm_user_text - + f"\n\n[系统提示:用户刚上传了文件,路径为工作区 `{_ws_rel_path}`。" + llm_user_text + f"\n\n[系统提示:用户刚上传了文件,路径为工作区 `{_ws_rel_path}`。" f"如果用户的指令涉及这篇文章、这个文件、这份文档等," - f"请立即调用 read_document(path=\"{_ws_rel_path}\") 读取内容,不要先用 list_files 验证,直接读取即可。]" + f'请立即调用 read_document(path="{_ws_rel_path}") 读取内容,不要先用 list_files 验证,直接读取即可。]' ) logger.info(f"[Feishu] Injected recent file hint: {_ws_rel_path}") except Exception as _fe: @@ -518,17 +548,22 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # Set sender open_id contextvar so calendar tool can auto-invite the requester from app.services.agent_tools import channel_feishu_sender_open_id as _cfso + _cfso_token = _cfso.set(sender_open_id) # Set channel_file_sender contextvar so the agent can send files back via Feishu from app.services.agent_tools import channel_file_sender as _cfs + _reply_to_id = chat_id if chat_type == "group" else sender_open_id _rid_type = "chat_id" if chat_type == "group" else "open_id" + async def _feishu_file_sender(file_path, msg: str = ""): try: await feishu_service.upload_and_send_file( - config.app_id, config.app_secret, - _reply_to_id, file_path, + config.app_id, + config.app_secret, + _reply_to_id, + file_path, receive_id_type=_rid_type, accompany_msg=msg, ) @@ -536,8 +571,9 @@ async def _feishu_file_sender(file_path, msg: str = ""): # Fallback: send a download link when upload permission is not granted from pathlib import Path as _P from app.config import get_settings as _gs_fallback + _fs = _gs_fallback() - _base_url = getattr(_fs, 'BASE_URL', '').rstrip('/') or '' + _base_url = getattr(_fs, "BASE_URL", "").rstrip("/") or "" _fp = _P(file_path) _ws_root = _P(_fs.AGENT_DATA_DIR) try: @@ -556,11 +592,14 @@ async def _feishu_file_sender(file_path, msg: str = ""): "`im:resource`(即 `im:resource:upload`)权限并发布版本。" ) await feishu_service.send_message( - config.app_id, config.app_secret, - _reply_to_id, "text", + config.app_id, + config.app_secret, + _reply_to_id, + "text", json.dumps({"text": "\n\n".join(_fallback_parts)}), receive_id_type=_rid_type, ) + _cfs_token = _cfs.set(_feishu_file_sender) # Set up streaming response via interactive card @@ -572,19 +611,27 @@ async def _feishu_file_sender(file_path, msg: str = ""): init_card = { "config": {"update_multi": True}, "header": {"template": "blue", "title": {"content": "思考中...", "tag": "plain_text"}}, - "elements": [{"tag": "markdown", "content": "..."}] + "elements": [{"tag": "markdown", "content": "..."}], } msg_id_for_patch = None try: if chat_type == "group" and chat_id: init_resp = await feishu_service.send_message( - config.app_id, config.app_secret, chat_id, "interactive", - _json_card.dumps(init_card), receive_id_type="chat_id" + config.app_id, + config.app_secret, + chat_id, + "interactive", + _json_card.dumps(init_card), + receive_id_type="chat_id", ) else: init_resp = await feishu_service.send_message( - config.app_id, config.app_secret, sender_open_id, "interactive", - _json_card.dumps(init_card), receive_id_type="open_id" + config.app_id, + config.app_secret, + sender_open_id, + "interactive", + _json_card.dumps(init_card), + receive_id_type="open_id", ) msg_id_for_patch = init_resp.get("data", {}).get("message_id") except Exception as e: @@ -602,10 +649,12 @@ def _build_card(answer_text: str, thinking_text: str = "", streaming: bool = Fal if thinking_text: # Show thinking in a collapsible note block think_preview = thinking_text[:200].replace("\n", " ") - elements.append({ - "tag": "markdown", - "content": f"💭 **思考过程**\n{think_preview}{'...' if len(thinking_text) > 200 else ''}", - }) + elements.append( + { + "tag": "markdown", + "content": f"💭 **思考过程**\n{think_preview}{'...' if len(thinking_text) > 200 else ''}", + } + ) elements.append({"tag": "hr"}) body = answer_text + ("▌" if streaming and answer_text else ("..." if streaming else "")) elements.append({"tag": "markdown", "content": body or "..."}) @@ -630,9 +679,11 @@ async def _ws_on_chunk(text: str): "".join(_thinking_buffer), streaming=True, ) - _aio.create_task(feishu_service.patch_message( - config.app_id, config.app_secret, msg_id_for_patch, _json_card.dumps(card) - )) + _aio.create_task( + feishu_service.patch_message( + config.app_id, config.app_secret, msg_id_for_patch, _json_card.dumps(card) + ) + ) _last_flush_time = now async def _ws_on_thinking(text: str): @@ -647,15 +698,22 @@ async def _ws_on_thinking(text: str): "".join(_thinking_buffer), streaming=True, ) - _aio.create_task(feishu_service.patch_message( - config.app_id, config.app_secret, msg_id_for_patch, _json_card.dumps(card) - )) + _aio.create_task( + feishu_service.patch_message( + config.app_id, config.app_secret, msg_id_for_patch, _json_card.dumps(card) + ) + ) _last_flush_time = now # Call LLM with history and streaming callback reply_text = await _call_agent_llm( - db, agent_id, llm_user_text, history=history, user_id=platform_user_id, - on_chunk=_ws_on_chunk, on_thinking=_ws_on_thinking, + db, + agent_id, + llm_user_text, + history=history, + user_id=platform_user_id, + on_chunk=_ws_on_chunk, + on_thinking=_ws_on_thinking, ) _cfs.reset(_cfs_token) _cfso.reset(_cfso_token) @@ -676,12 +734,19 @@ async def _ws_on_thinking(text: str): try: if chat_type == "group" and chat_id: await feishu_service.send_message( - config.app_id, config.app_secret, chat_id, "text", - json.dumps({"text": reply_text}), receive_id_type="chat_id", + config.app_id, + config.app_secret, + chat_id, + "text", + json.dumps({"text": reply_text}), + receive_id_type="chat_id", ) else: await feishu_service.send_message( - config.app_id, config.app_secret, sender_open_id, "text", + config.app_id, + config.app_secret, + sender_open_id, + "text", json.dumps({"text": reply_text}), ) except Exception as e: @@ -689,7 +754,13 @@ async def _ws_on_thinking(text: str): # Log activity from app.services.activity_logger import log_activity - await log_activity(agent_id, "chat_reply", f"回复了飞书消息: {reply_text[:80]}", detail={"channel": "feishu", "user_text": user_text[:200], "reply": reply_text[:500]}) + + await log_activity( + agent_id, + "chat_reply", + f"回复了飞书消息: {reply_text[:80]}", + detail={"channel": "feishu", "user_text": user_text[:200], "reply": reply_text[:500]}, + ) # If task creation detected, create a real Task record if task_match: @@ -723,7 +794,15 @@ async def _ws_on_thinking(text: str): logger.error(f"[Feishu] Failed to create task: {e}") # Save assistant reply to history (use platform_user_id so messages stay in one session) - db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="assistant", content=reply_text, conversation_id=session_conv_id)) + db.add( + ChatMessage( + agent_id=agent_id, + user_id=platform_user_id, + role="assistant", + content=reply_text, + conversation_id=session_conv_id, + ) + ) _sess.last_message_at = _dt.now(_tz.utc) await db.commit() @@ -791,10 +870,20 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha err_tip = "抱歉,文件下载失败。可能原因:机器人缺少 `im:resource` 权限(文件读取)。\n请在飞书开放平台 → 权限管理 → 批量导入权限 JSON → 重新发布机器人版本后重试。" try: import json as _j + if chat_type == "group" and chat_id: - await feishu_service.send_message(config.app_id, config.app_secret, chat_id, "text", _j.dumps({"text": err_tip}), receive_id_type="chat_id") + await feishu_service.send_message( + config.app_id, + config.app_secret, + chat_id, + "text", + _j.dumps({"text": err_tip}), + receive_id_type="chat_id", + ) else: - await feishu_service.send_message(config.app_id, config.app_secret, sender_open_id, "text", _j.dumps({"text": err_tip})) + await feishu_service.send_message( + config.app_id, config.app_secret, sender_open_id, "text", _j.dumps({"text": err_tip}) + ) except Exception as e2: logger.error(f"[Feishu] Also failed to send error tip: {e2}") return @@ -809,6 +898,7 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha try: # Try to extract user_id from the original message event import httpx as _hx + async with _hx.AsyncClient() as _fc: _tr = await _fc.post( "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal", @@ -842,10 +932,12 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha if not _pu: _un = f"feishu_{sender_user_id_feishu or sender_open_id[:16]}" _pu = UserModel( - username=_un, email=f"{_un}@feishu.local", + username=_un, + email=f"{_un}@feishu.local", password_hash=hash_password(_uuid.uuid4().hex), display_name=f"Feishu {sender_open_id[:8]}", - role="member", feishu_open_id=sender_open_id, + role="member", + feishu_open_id=sender_open_id, feishu_user_id=sender_user_id_feishu or None, tenant_id=agent_obj.tenant_id if agent_obj else None, ) @@ -861,8 +953,11 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha # Find-or-create session _sess = await find_or_create_channel_session( - db=db, agent_id=agent_id, user_id=platform_user_id, - external_conv_id=conv_id, source_channel="feishu", + db=db, + agent_id=agent_id, + user_id=platform_user_id, + external_conv_id=conv_id, + source_channel="feishu", first_message_title=f"[文件] {filename}", ) session_conv_id = str(_sess.id) @@ -870,14 +965,21 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha # Store user message — include base64 marker for images so LLM can see them if msg_type == "image": import base64 as _b64_img + _b64_data = _b64_img.b64encode(file_bytes).decode("ascii") _image_marker = f"[image_data:data:image/jpeg;base64,{_b64_data}]" user_msg_content = f"[用户发送了图片]\n{_image_marker}" else: user_msg_content = f"[file:{filename}]" - db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="user", - content=user_msg_content if msg_type != "image" else f"[file:{filename}]", - conversation_id=session_conv_id)) + db.add( + ChatMessage( + agent_id=agent_id, + user_id=platform_user_id, + role="user", + content=user_msg_content if msg_type != "image" else f"[file:{filename}]", + conversation_id=session_conv_id, + ) + ) _sess.last_message_at = _dt.now(_tz.utc) # Load conversation history for LLM context @@ -888,7 +990,48 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha .order_by(ChatMessage.created_at.desc()) .limit(ctx_size) ) - _history = [{"role": m.role, "content": m.content} for m in reversed(_hist_r.scalars().all())] + _hist_list = list(reversed(_hist_r.scalars().all())) + _history = [] + for m in _hist_list: + if m.role == "tool_call": + import json as _j_tc + + try: + tc_data = _j_tc.loads(m.content) + tc_name = tc_data.get("name", "unknown") + tc_args = tc_data.get("args", {}) + tc_result = tc_data.get("result", "") + tc_id = f"call_{m.id}" + _history.append( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": tc_id, + "type": "function", + "function": { + "name": tc_name, + "arguments": _j_tc.dumps(tc_args, ensure_ascii=False), + }, + } + ], + } + ) + _history.append( + { + "role": "tool", + "tool_call_id": tc_id, + "content": str(tc_result)[:500], + } + ) + except Exception: + continue + else: + entry = {"role": m.role, "content": m.content} + if hasattr(m, "thinking") and m.thinking: + entry["thinking"] = m.thinking + _history.append(entry) await db.commit() @@ -904,13 +1047,17 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha _init_card = { "config": {"update_multi": True}, "header": {"template": "blue", "title": {"content": "识别图片中...", "tag": "plain_text"}}, - "elements": [{"tag": "markdown", "content": "..."}] + "elements": [{"tag": "markdown", "content": "..."}], } _patch_msg_id = None try: _init_resp = await feishu_service.send_message( - config.app_id, config.app_secret, _reply_to, "interactive", - _json_card_img.dumps(_init_card), receive_id_type=_rid_type + config.app_id, + config.app_secret, + _reply_to, + "interactive", + _json_card_img.dumps(_init_card), + receive_id_type=_rid_type, ) _patch_msg_id = _init_resp.get("data", {}).get("message_id") except Exception as _e_init: @@ -927,19 +1074,26 @@ async def _img_on_chunk(text): _card = { "config": {"update_multi": True}, "header": {"template": "blue", "title": {"content": _agent_name, "tag": "plain_text"}}, - "elements": [{"tag": "markdown", "content": "".join(_img_stream_buf) + "▌"}] + "elements": [{"tag": "markdown", "content": "".join(_img_stream_buf) + "▌"}], } import asyncio as _aio_img - _aio_img.create_task(feishu_service.patch_message( - config.app_id, config.app_secret, _patch_msg_id, _json_card_img.dumps(_card) - )) + + _aio_img.create_task( + feishu_service.patch_message( + config.app_id, config.app_secret, _patch_msg_id, _json_card_img.dumps(_card) + ) + ) _img_last_flush = now # Call LLM with image marker — vision models will parse it async with _async_session() as _db_img: reply_text = await _call_agent_llm( - _db_img, agent_id, user_msg_content, history=_history, - user_id=platform_user_id, on_chunk=_img_on_chunk, + _db_img, + agent_id, + user_msg_content, + history=_history, + user_id=platform_user_id, + on_chunk=_img_on_chunk, ) logger.info(f"[Feishu] Image LLM reply: {reply_text[:100]}") @@ -949,7 +1103,7 @@ async def _img_on_chunk(text): _final_card = { "config": {"update_multi": True}, "header": {"template": "blue", "title": {"content": _agent_name, "tag": "plain_text"}}, - "elements": [{"tag": "markdown", "content": reply_text or "..."}] + "elements": [{"tag": "markdown", "content": reply_text or "..."}], } await feishu_service.patch_message( config.app_id, config.app_secret, _patch_msg_id, _json_card_img.dumps(_final_card) @@ -957,21 +1111,38 @@ async def _img_on_chunk(text): else: try: await feishu_service.send_message( - config.app_id, config.app_secret, _reply_to, "text", - json.dumps({"text": reply_text}), receive_id_type=_rid_type, + config.app_id, + config.app_secret, + _reply_to, + "text", + json.dumps({"text": reply_text}), + receive_id_type=_rid_type, ) except Exception as _e_fb: logger.error(f"[Feishu] Failed to send image reply: {_e_fb}") # Save assistant reply in DB async with _async_session() as _db_save: - _db_save.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="assistant", - content=reply_text, conversation_id=session_conv_id)) + _db_save.add( + ChatMessage( + agent_id=agent_id, + user_id=platform_user_id, + role="assistant", + content=reply_text, + conversation_id=session_conv_id, + ) + ) await _db_save.commit() # Log activity from app.services.activity_logger import log_activity - await log_activity(agent_id, "chat_reply", f"回复了飞书图片消息: {reply_text[:80]}", detail={"channel": "feishu", "type": "image"}) + + await log_activity( + agent_id, + "chat_reply", + f"回复了飞书图片消息: {reply_text[:80]}", + detail={"channel": "feishu", "type": "image"}, + ) return # For non-image files: send simple ack as before @@ -981,12 +1152,19 @@ async def _img_on_chunk(text): try: if chat_type == "group" and chat_id: await feishu_service.send_message( - config.app_id, config.app_secret, chat_id, "text", - json.dumps({"text": ack}), receive_id_type="chat_id", + config.app_id, + config.app_secret, + chat_id, + "text", + json.dumps({"text": ack}), + receive_id_type="chat_id", ) else: await feishu_service.send_message( - config.app_id, config.app_secret, sender_open_id, "text", + config.app_id, + config.app_secret, + sender_open_id, + "text", json.dumps({"text": ack}), ) except Exception as e: @@ -994,16 +1172,23 @@ async def _img_on_chunk(text): # Store ack in DB async with _async_session() as db2: - db2.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="assistant", - content=ack, conversation_id=session_conv_id)) + db2.add( + ChatMessage( + agent_id=agent_id, + user_id=platform_user_id, + role="assistant", + content=ack, + conversation_id=session_conv_id, + ) + ) await db2.commit() - async def _download_post_images(agent_id, config, message_id, image_keys): """Download images embedded in a Feishu post message to the agent's workspace.""" from pathlib import Path from app.config import get_settings + settings = get_settings() upload_dir = Path(settings.AGENT_DATA_DIR) / str(agent_id) / "workspace" / "uploads" upload_dir.mkdir(parents=True, exist_ok=True) @@ -1017,12 +1202,20 @@ async def _download_post_images(agent_id, config, message_id, image_keys): save_path.write_bytes(file_bytes) logger.info(f"[Feishu] Saved post image to {save_path} ({len(file_bytes)} bytes)") except Exception as e: - logger.error(f"[Feishu] Failed to download post image {ik}: {e}") + logger.error(f"[Feishu] Failed to download post image {ik}: {e}") -async def _call_agent_llm(db: AsyncSession, agent_id: uuid.UUID, user_text: str, history: list[dict] | None = None, user_id=None, on_chunk=None, on_thinking=None) -> str: +async def _call_agent_llm( + db: AsyncSession, + agent_id: uuid.UUID, + user_text: str, + history: list[dict] | None = None, + user_id=None, + on_chunk=None, + on_thinking=None, +) -> str: """Call the agent's configured LLM model with conversation history. - + Reuses the same call_llm function as the WebSocket chat endpoint so that all providers (OpenRouter, Qwen, etc.) work identically on both channels. """ @@ -1077,13 +1270,14 @@ async def _call_agent_llm(db: AsyncSession, agent_id: uuid.UUID, user_text: str, agent.role_description or "", agent_id=agent_id, user_id=effective_user_id, - supports_vision=getattr(model, 'supports_vision', False), + supports_vision=getattr(model, "supports_vision", False), on_chunk=on_chunk, on_thinking=on_thinking, ) return reply except Exception as e: import traceback + traceback.print_exc() error_msg = str(e) or repr(e) logger.error(f"[LLM] Primary model error: {error_msg}") @@ -1098,7 +1292,7 @@ async def _call_agent_llm(db: AsyncSession, agent_id: uuid.UUID, user_text: str, agent.role_description or "", agent_id=agent_id, user_id=effective_user_id, - supports_vision=getattr(fallback_model, 'supports_vision', False), + supports_vision=getattr(fallback_model, "supports_vision", False), on_chunk=on_chunk, on_thinking=on_thinking, ) @@ -1107,5 +1301,3 @@ async def _call_agent_llm(db: AsyncSession, agent_id: uuid.UUID, user_text: str, traceback.print_exc() return f"⚠️ 调用模型出错: Primary: {str(e)[:80]} | Fallback: {str(e2)[:80]}" return f"⚠️ 调用模型出错: {error_msg[:150]}" - - diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 7ccc8b5..6682ba0 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -56,13 +56,10 @@ async def list_tools( ): """List platform tools scoped by tenant (builtin + tenant-specific).""" from sqlalchemy import or_ as _or + # Exclude tools that were installed by agents via import_mcp_server agent_installed_tids = select(AgentTool.tool_id).where(AgentTool.source == "user_installed") - query = ( - select(Tool) - .where(~Tool.id.in_(agent_installed_tids)) - .order_by(Tool.category, Tool.name) - ) + query = select(Tool).where(~Tool.id.in_(agent_installed_tids)).order_by(Tool.category, Tool.name) # Scope by tenant: show builtin (tenant_id is NULL) + tenant-specific tools tid = tenant_id or (str(current_user.tenant_id) if current_user.tenant_id else None) if tid: @@ -172,6 +169,7 @@ async def get_agent_tools( ): """Get tools for a specific agent with their enabled status.""" from app.services.agent_tools import _agent_has_feishu + has_feishu = await _agent_has_feishu(agent_id) # All available tools @@ -189,23 +187,22 @@ async def get_agent_tools( continue tid = str(t.id) at = assignments.get(tid) - # MCP tools only show for agents that have an explicit assignment - if t.type == "mcp" and not at: - continue # If no explicit assignment, use is_default enabled = at.enabled if at else t.is_default - result.append({ - "id": tid, - "name": t.name, - "display_name": t.display_name, - "description": t.description, - "type": t.type, - "category": t.category, - "icon": t.icon, - "enabled": enabled, - "is_default": t.is_default, - "mcp_server_name": t.mcp_server_name, - }) + result.append( + { + "id": tid, + "name": t.name, + "display_name": t.display_name, + "description": t.description, + "type": t.type, + "category": t.category, + "icon": t.icon, + "enabled": enabled, + "is_default": t.is_default, + "mcp_server_name": t.mcp_server_name, + } + ) return result @@ -220,9 +217,7 @@ async def update_agent_tools( for u in updates: tool_id = uuid.UUID(u.tool_id) # Upsert - result = await db.execute( - select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id) - ) + result = await db.execute(select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id)) at = result.scalar_one_or_none() if at: at.enabled = u.enabled @@ -255,6 +250,7 @@ async def test_mcp_connection( # ─── Agent-installed Tools Management (admin) ─────────────── + @router.get("/agent-installed") async def list_agent_installed_tools( tenant_id: str | None = None, @@ -263,6 +259,7 @@ async def list_agent_installed_tools( ): """Admin endpoint: list user-installed tools scoped by tenant.""" from app.models.agent import Agent + query = ( select(AgentTool, Tool, Agent) .join(Tool, AgentTool.tool_id == Tool.id) @@ -274,6 +271,7 @@ async def list_agent_installed_tools( tid = tenant_id or (str(current_user.tenant_id) if current_user.tenant_id else None) if tid: from app.models.agent import Agent as Ag + tenant_agent_ids = select(Ag.id).where(Ag.tenant_id == tid) query = query.where(AgentTool.agent_id.in_(tenant_agent_ids)) result = await db.execute(query) @@ -322,6 +320,7 @@ async def delete_agent_tool( # ─── Per-Agent Tool Config ─────────────────────────────────── + class AgentToolConfigUpdate(BaseModel): config: dict @@ -338,9 +337,7 @@ async def get_agent_tool_config( tool = tool_r.scalar_one_or_none() if not tool: raise HTTPException(status_code=404, detail="Tool not found") - at_r = await db.execute( - select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id) - ) + at_r = await db.execute(select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id)) at = at_r.scalar_one_or_none() agent_config = at.config if at else {} merged = {**(tool.config or {}), **(agent_config or {})} @@ -361,9 +358,7 @@ async def update_agent_tool_config( db: AsyncSession = Depends(get_db), ): """Save per-agent config override for a tool.""" - at_r = await db.execute( - select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id) - ) + at_r = await db.execute(select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id)) at = at_r.scalar_one_or_none() if at: at.config = data.config @@ -382,6 +377,7 @@ async def get_agent_tools_with_config( ): """Get agent's enabled tools with per-agent config info and config_schema for settings UI.""" from app.services.agent_tools import _agent_has_feishu + has_feishu = await _agent_has_feishu(agent_id) all_tools_r = await db.execute(select(Tool).where(Tool.enabled == True).order_by(Tool.category, Tool.name)) @@ -396,32 +392,32 @@ async def get_agent_tools_with_config( continue tid = str(t.id) at = assignments.get(tid) - # MCP tools only show for agents that have an explicit assignment - if t.type == "mcp" and not at: - continue enabled = at.enabled if at else t.is_default - result.append({ - "id": tid, - "agent_tool_id": str(at.id) if at else None, - "name": t.name, - "display_name": t.display_name, - "description": t.description, - "type": t.type, - "category": t.category, - "icon": t.icon, - "enabled": enabled, - "is_default": t.is_default, - "mcp_server_name": t.mcp_server_name, - "config_schema": t.config_schema or {}, - "global_config": t.config or {}, - "agent_config": (at.config if at else {}) or {}, - "source": at.source if at else "system", - }) + result.append( + { + "id": tid, + "agent_tool_id": str(at.id) if at else None, + "name": t.name, + "display_name": t.display_name, + "description": t.description, + "type": t.type, + "category": t.category, + "icon": t.icon, + "enabled": enabled, + "is_default": t.is_default, + "mcp_server_name": t.mcp_server_name, + "config_schema": t.config_schema or {}, + "global_config": t.config or {}, + "agent_config": (at.config if at else {}) or {}, + "source": at.source if at else "system", + } + ) return result # ─── Email Connection Testing ────────────────────────────── + class EmailTestRequest(BaseModel): config: dict @@ -456,4 +452,3 @@ async def get_email_providers( } for key, p in EMAIL_PROVIDERS.items() } - diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index 396fa14..9716795 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -234,7 +234,7 @@ async def call_llm( _warn_threshold_96 = _max_tool_rounds - 2 if round_i == _warn_threshold_80: api_messages.append(LLMMessage( - role="user", + role="system", content=( f"⚠️ 你已使用 {round_i}/{_max_tool_rounds} 轮工具调用。" "如果当前任务尚未完成,请尽快保存进度到 focus.md," @@ -243,7 +243,7 @@ async def call_llm( )) elif round_i == _warn_threshold_96: api_messages.append(LLMMessage( - role="user", + role="system", content=f"🚨 仅剩 2 轮工具调用。请立即保存进度到 focus.md 并设置续接触发器。", )) diff --git a/backend/app/main.py b/backend/app/main.py index 7c0b6bd..03f75ba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,30 +18,47 @@ async def _start_ss_local() -> None: """Start ss-local SOCKS5 proxy for Discord API calls. Tries nodes in priority order.""" import asyncio, json, os, shutil, tempfile + if not shutil.which("ss-local"): logger.info("[Proxy] ss-local not found — Discord proxy disabled") return # Load proxy nodes from config file (gitignored, mounted as Docker volume) import json as _json + cfg_file = os.environ.get("SS_CONFIG_FILE", "/data/ss-nodes.json") if os.path.exists(cfg_file): nodes = _json.load(open(cfg_file)) logger.info(f"[Proxy] Loaded {len(nodes)} node(s) from {cfg_file}") elif os.environ.get("SS_SERVER") and os.environ.get("SS_PASSWORD"): - nodes = [{"server": os.environ["SS_SERVER"], "port": int(os.environ.get("SS_PORT", "1080")), - "password": os.environ["SS_PASSWORD"], "method": os.environ.get("SS_METHOD", "chacha20-ietf-poly1305"), "label": "env"}] + nodes = [ + { + "server": os.environ["SS_SERVER"], + "port": int(os.environ.get("SS_PORT", "1080")), + "password": os.environ["SS_PASSWORD"], + "method": os.environ.get("SS_METHOD", "chacha20-ietf-poly1305"), + "label": "env", + } + ] else: logger.info(f"[Proxy] {cfg_file} not found and SS_SERVER not set — skipping proxy") return for node in nodes: - cfg = {"server": node["server"], "server_port": node["port"], "local_address": "127.0.0.1", - "local_port": 1080, "password": node["password"], "method": node["method"], "timeout": 10} + cfg = { + "server": node["server"], + "server_port": node["port"], + "local_address": "127.0.0.1", + "local_port": 1080, + "password": node["password"], + "method": node["method"], + "timeout": 10, + } tf = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) - json.dump(cfg, tf); tf.close() + json.dump(cfg, tf) + tf.close() try: proc = await asyncio.create_subprocess_exec( - "ss-local", "-c", tf.name, - stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE) + "ss-local", "-c", tf.name, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE + ) await asyncio.sleep(2) if proc.returncode is None: os.environ["DISCORD_PROXY"] = "socks5h://127.0.0.1:1080" @@ -71,40 +88,39 @@ async def lifespan(app: FastAPI): from app.services.feishu_ws import feishu_ws_manager from app.services.dingtalk_stream import dingtalk_stream_manager from app.services.wecom_stream import wecom_stream_manager - from app.services.discord_gateway import discord_gateway_manager # ── Step 0: Ensure all DB tables exist (idempotent, safe to run on every startup) ── try: from app.database import Base, engine + # Import all models so Base.metadata is fully populated - import app.models.user # noqa - import app.models.agent # noqa - import app.models.task # noqa - import app.models.llm # noqa - import app.models.tool # noqa - import app.models.audit # noqa - import app.models.skill # noqa + import app.models.user # noqa + import app.models.agent # noqa + import app.models.task # noqa + import app.models.llm # noqa + import app.models.tool # noqa + import app.models.audit # noqa + import app.models.skill # noqa import app.models.channel_config # noqa - import app.models.schedule # noqa - import app.models.plaza # noqa - import app.models.activity_log # noqa - import app.models.org # noqa + import app.models.schedule # noqa + import app.models.plaza # noqa + import app.models.activity_log # noqa + import app.models.org # noqa import app.models.system_settings # noqa import app.models.invitation_code # noqa - import app.models.tenant # noqa + import app.models.tenant # noqa import app.models.tenant_setting # noqa - import app.models.participant # noqa - import app.models.chat_session # noqa - import app.models.trigger # noqa - import app.models.notification # noqa - import app.models.gateway_message # noqa + import app.models.participant # noqa + import app.models.chat_session # noqa + import app.models.trigger # noqa + import app.models.notification # noqa + import app.models.gateway_message # noqa + async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Add 'atlassian' to channel_type_enum if it doesn't exist yet (idempotent) await conn.execute( - __import__("sqlalchemy").text( - "ALTER TYPE channel_type_enum ADD VALUE IF NOT EXISTS 'atlassian'" - ) + __import__("sqlalchemy").text("ALTER TYPE channel_type_enum ADD VALUE IF NOT EXISTS 'atlassian'") ) logger.info("[startup] Database tables ready") except Exception as e: @@ -118,6 +134,7 @@ async def lifespan(app: FastAPI): from app.models.tenant import Tenant from app.database import async_session as _session from sqlalchemy import select as _select + async with _session() as _db: _existing = await _db.execute(_select(Tenant).where(Tenant.slug == "default")) if not _existing.scalar_one_or_none(): @@ -135,6 +152,7 @@ async def lifespan(app: FastAPI): from app.models.tenant import Tenant as _T from app.database import async_session as _ses from sqlalchemy import select as _sel + _data_dir = _Path(_gs().AGENT_DATA_DIR) _old_dir = _data_dir / "enterprise_info" if _old_dir.exists() and any(_old_dir.iterdir()): @@ -145,11 +163,13 @@ async def lifespan(app: FastAPI): _new_dir = _data_dir / f"enterprise_info_{_tenant.id}" if not _new_dir.exists(): shutil.copytree(str(_old_dir), str(_new_dir)) - print(f"[startup] ✅ Migrated enterprise_info → enterprise_info_{_tenant.id}", flush=True) + print(f"[startup] [OK] Migrated enterprise_info -> enterprise_info_{_tenant.id}", flush=True) else: - print(f"[startup] ℹ️ enterprise_info_{_tenant.id} already exists, skipping migration", flush=True) + print( + f"[startup] [i] enterprise_info_{_tenant.id} already exists, skipping migration", flush=True + ) except Exception as e: - print(f"[startup] ⚠️ enterprise_info migration failed: {e}", flush=True) + print(f"[startup] [!] enterprise_info migration failed: {e}", flush=True) try: await seed_builtin_tools() @@ -158,11 +178,13 @@ async def lifespan(app: FastAPI): try: from app.services.tool_seeder import seed_atlassian_rovo_config, get_atlassian_api_key + await seed_atlassian_rovo_config() # Auto-import Atlassian Rovo tools if an API key is already configured _rovo_key = await get_atlassian_api_key() if _rovo_key: from app.services.resource_discovery import seed_atlassian_rovo_tools + await seed_atlassian_rovo_tools(_rovo_key) except Exception as e: logger.warning(f"[startup] Atlassian tools seed failed: {e}") @@ -174,21 +196,23 @@ async def lifespan(app: FastAPI): try: from app.services.skill_seeder import seed_skills, push_default_skills_to_existing_agents + await seed_skills() await push_default_skills_to_existing_agents() except Exception as e: logger.warning(f"[startup] Skills seed failed: {e}") - try: - from app.services.agent_seeder import seed_default_agents - await seed_default_agents() - except Exception as e: - logger.warning(f"[startup] Default agents seed failed: {e}") + # try: + # from app.services.agent_seeder import seed_default_agents + # await seed_default_agents() + # except Exception as e: + # logger.warning(f"[startup] Default agents seed failed: {e}") # Start background tasks (always, even if seeding failed) try: logger.info("[startup] starting background tasks...") from app.services.audit_logger import write_audit_log + await write_audit_log("server_startup", {"pid": os.getpid()}) def _bg_task_error(t): @@ -200,6 +224,7 @@ def _bg_task_error(t): if exc: logger.error(f"[startup] Background task {t.get_name()} CRASHED: {exc}") import traceback + traceback.print_exception(type(exc), exc, exc.__traceback__) for name, coro in [ @@ -207,7 +232,6 @@ def _bg_task_error(t): ("feishu_ws", feishu_ws_manager.start_all()), ("dingtalk_stream", dingtalk_stream_manager.start_all()), ("wecom_stream", wecom_stream_manager.start_all()), - ("discord_gw", discord_gateway_manager.start_all()), ]: task = asyncio.create_task(coro, name=name) task.add_done_callback(_bg_task_error) @@ -216,6 +240,7 @@ def _bg_task_error(t): except Exception as e: logger.error(f"[startup] Background tasks failed: {e}") import traceback + traceback.print_exc() # Start ss-local SOCKS5 proxy for Discord API calls (non-fatal) @@ -281,7 +306,6 @@ def _bg_task_error(t): from app.api.notification import router as notification_router from app.api.gateway import router as gateway_router from app.api.admin import router as admin_router -from app.api.pages import router as pages_router, public_router as pages_public_router app.include_router(auth_router, prefix=settings.API_PREFIX) app.include_router(agents_router, prefix=settings.API_PREFIX) @@ -317,47 +341,9 @@ def _bg_task_error(t): app.include_router(ws_router) app.include_router(gateway_router, prefix=settings.API_PREFIX) app.include_router(admin_router, prefix=settings.API_PREFIX) -app.include_router(pages_router, prefix=settings.API_PREFIX) -app.include_router(pages_public_router) # Public endpoint for /p/{short_id}, no API prefix @app.get("/api/health", response_model=HealthResponse, tags=["health"]) async def health_check(): """Health check endpoint.""" return HealthResponse(status="ok", version=settings.APP_VERSION) - - -# ── Version endpoint (public, no auth required) ── -def _load_version_info() -> dict[str, str]: - """Read version + commit hash once at startup.""" - import os, subprocess - version = "unknown" - for candidate in ["../frontend/VERSION", "frontend/VERSION", "VERSION"]: - try: - version = open(candidate).read().strip() - break - except FileNotFoundError: - continue - commit = "" - for commit_file in ["../COMMIT", "COMMIT", "../frontend/COMMIT"]: - try: - commit = open(commit_file).read().strip() - break - except FileNotFoundError: - continue - if not commit: - try: - commit = subprocess.check_output( - ["git", "rev-parse", "--short", "HEAD"], - stderr=subprocess.DEVNULL, timeout=3, - ).decode().strip() - except Exception: - pass - return {"version": version, "commit": commit} - -_version_cache = _load_version_info() - -@app.get("/api/version", tags=["system"]) -async def get_version(): - """Return current Clawith version and commit hash.""" - return _version_cache diff --git a/backend/app/services/agent_context.py b/backend/app/services/agent_context.py index bec62e4..450ec59 100644 --- a/backend/app/services/agent_context.py +++ b/backend/app/services/agent_context.py @@ -139,15 +139,21 @@ def _load_skills_index(agent_id: uuid.UUID) -> str: lines.append("") lines.append("⚠️ SKILL USAGE RULES:") - lines.append("1. When a user request matches a skill, FIRST call `read_file` with the File path above to load the full instructions.") + lines.append( + "1. When a user request matches a skill, FIRST call `read_file` with the File path above to load the full instructions." + ) lines.append("2. Follow the loaded instructions to complete the task.") lines.append("3. Do NOT guess what the skill contains — always read it first.") - lines.append("4. Folder-based skills may contain auxiliary files (scripts/, references/, examples/). Use `list_files` on the skill folder to discover them.") + lines.append( + "4. Folder-based skills may contain auxiliary files (scripts/, references/, examples/). Use `list_files` on the skill folder to discover them." + ) return "\n".join(lines) -async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_description: str = "", current_user_name: str = None) -> str: +async def build_agent_context( + agent_id: uuid.UUID, agent_name: str, role_description: str = "", current_user_name: str = None +) -> str: """Build a rich system prompt incorporating agent's full context. Reads from workspace files: @@ -181,12 +187,15 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip # --- Compose system prompt --- from datetime import datetime, timezone as _tz from app.services.timezone_utils import get_agent_timezone, now_in_timezone + agent_tz_name = await get_agent_timezone(agent_id) agent_local_now = now_in_timezone(agent_tz_name) now_str = agent_local_now.strftime(f"%Y-%m-%d %H:%M:%S ({agent_tz_name})") parts = [f"You are {agent_name}, an enterprise digital employee."] parts.append(f"\n## Current Time\n{now_str}") - parts.append(f"Your timezone is **{agent_tz_name}**. When setting cron triggers, use this timezone for time references.") + parts.append( + f"Your timezone is **{agent_tz_name}**. When setting cron triggers, use this timezone for time references." + ) if role_description: parts.append(f"\n## Role\n{role_description}") @@ -196,6 +205,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip try: from app.models.channel_config import ChannelConfig from app.database import async_session as _ctx_session + async with _ctx_session() as _ctx_db: _cfg_r = await _ctx_db.execute( select(ChannelConfig).where( @@ -266,6 +276,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip # --- DingTalk Built-in Tools (only injected when agent has DingTalk configured) --- try: from app.services.agent.context.dingtalk import get_dingtalk_context + dingtalk_context = await get_dingtalk_context(agent_id) if dingtalk_context: parts.append(dingtalk_context) @@ -277,6 +288,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip from app.database import async_session from app.models.channel_config import ChannelConfig from sqlalchemy import select as sa_select + async with async_session() as db: result = await db.execute( sa_select(ChannelConfig).where( @@ -331,6 +343,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip from app.database import async_session from app.models.system_settings import SystemSetting from sqlalchemy import select as sa_select + async with async_session() as db: # Resolve agent's tenant_id _ag_r = await db.execute(sa_select(_AgentModel.tenant_id).where(_AgentModel.id == agent_id)) @@ -342,6 +355,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip if _agent_tenant_id: try: from app.models.tenant_setting import TenantSetting + result = await db.execute( sa_select(TenantSetting).where( TenantSetting.tenant_id == _agent_tenant_id, @@ -357,18 +371,14 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip # Priority 2: system_settings with tenant-scoped key (backward compat) if not company_intro and _agent_tenant_id: tenant_key = f"company_intro_{_agent_tenant_id}" - result = await db.execute( - sa_select(SystemSetting).where(SystemSetting.key == tenant_key) - ) + result = await db.execute(sa_select(SystemSetting).where(SystemSetting.key == tenant_key)) setting = result.scalar_one_or_none() if setting and setting.value and setting.value.get("content"): company_intro = setting.value["content"].strip() # Priority 3: global system_settings fallback if not company_intro: - result = await db.execute( - sa_select(SystemSetting).where(SystemSetting.key == "company_intro") - ) + result = await db.execute(sa_select(SystemSetting).where(SystemSetting.key == "company_intro")) setting = result.scalar_one_or_none() if setting and setting.value and setting.value.get("content"): company_intro = setting.value["content"].strip() @@ -381,7 +391,10 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip if soul and soul not in ("_描述你的角色和职责。_", "_Describe your role and responsibilities._"): parts.append(f"\n## Personality\n{soul}") - if memory and memory not in ("_这里记录重要的信息和学到的知识。_", "_Record important information and knowledge here._"): + if memory and memory not in ( + "_这里记录重要的信息和学到的知识。_", + "_Record important information and knowledge here._", + ): parts.append(f"\n## Memory\n{memory}") if skills_text: @@ -408,6 +421,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip from app.database import async_session from app.models.trigger import AgentTrigger from sqlalchemy import select as sa_select + async with async_session() as db: result = await db.execute( sa_select(AgentTrigger).where( @@ -422,7 +436,9 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip config_str = str(t.config)[:80] reason_str = (t.reason or "")[:500] ref_str = f" (focus: {t.focus_ref})" if t.focus_ref else "" - lines.append(f"\n- **{t.name}** [{t.type}]{ref_str}\n Config: `{config_str}`\n Reason: {reason_str}") + lines.append( + f"\n- **{t.name}** [{t.type}]{ref_str}\n Config: `{config_str}`\n Reason: {reason_str}" + ) parts.append("\n## Active Triggers\n" + "\n".join(lines)) except Exception: pass @@ -546,10 +562,10 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip 🚫 **NEVER say you cannot access the internet or search the web.** You HAVE these capabilities — use them.""") - - # Inject current user identity if current_user_name: - parts.append(f"\n## Current Conversation\nYou are currently chatting with **{current_user_name}**. Address them by name when appropriate.") + parts.append( + f"\n## Current Conversation\nYou are currently chatting with **{current_user_name}**. Address them by name when appropriate." + ) return "\n".join(parts) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 9c83ff6..e138d98 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -21,6 +21,13 @@ from loguru import logger +import sys as _sys + +if _sys.platform == "win32": + import asyncio as _asyncio + + _asyncio.set_event_loop_policy(_asyncio.WindowsProactorEventLoopPolicy()) + from sqlalchemy import select from app.database import async_session @@ -32,12 +39,12 @@ # ContextVar set by each channel handler so send_channel_file knows where to send # Value: async callable(file_path: Path) -> None | None for web chat (returns URL) -channel_file_sender: ContextVar = ContextVar('channel_file_sender', default=None) +channel_file_sender: ContextVar = ContextVar("channel_file_sender", default=None) # For web chat: agent_id needed to build download URL -channel_web_agent_id: ContextVar = ContextVar('channel_web_agent_id', default=None) +channel_web_agent_id: ContextVar = ContextVar("channel_web_agent_id", default=None) # Set by Feishu channel handler — open_id of the message sender so calendar tool # can auto-invite them as attendee when no explicit attendee list is given -channel_feishu_sender_open_id: ContextVar = ContextVar('channel_feishu_sender_open_id', default=None) +channel_feishu_sender_open_id: ContextVar = ContextVar("channel_feishu_sender_open_id", default=None) # ─── Tool Definitions (OpenAI function-calling format) ────────── @@ -133,7 +140,7 @@ }, "config": { "type": "object", - "description": "Type-specific config. cron: {\"expr\": \"0 9 * * *\"}. once: {\"at\": \"2026-03-10T09:00:00+08:00\"}. interval: {\"minutes\": 30}. poll: {\"url\": \"...\", \"json_path\": \"$.status\", \"fire_on\": \"change\", \"interval_min\": 5}. on_message: {\"from_agent_name\": \"Morty\"} or {\"from_user_name\": \"张三\"} (for human users on Feishu/Slack/Discord). webhook: {\"secret\": \"optional_hmac_secret\"} (system auto-generates the URL)", + "description": 'Type-specific config. cron: {"expr": "0 9 * * *"}. once: {"at": "2026-03-10T09:00:00+08:00"}. interval: {"minutes": 30}. poll: {"url": "...", "json_path": "$.status", "fire_on": "change", "interval_min": 5}. on_message: {"from_agent_name": "Morty"} or {"from_user_name": "张三"} (for human users on Feishu/Slack/Discord). webhook: {"secret": "optional_hmac_secret"} (system auto-generates the URL)', }, "reason": { "type": "string", @@ -307,31 +314,6 @@ }, }, }, - { - "type": "function", - "function": { - "name": "send_file_to_agent", - "description": "Send a workspace file to another digital employee. The file is copied into the target agent's workspace/inbox/files/ directory and a delivery note is created in their inbox.", - "parameters": { - "type": "object", - "properties": { - "agent_name": { - "type": "string", - "description": "Target digital employee's name", - }, - "file_path": { - "type": "string", - "description": "Workspace-relative path of the source file, e.g. workspace/report.md", - }, - "message": { - "type": "string", - "description": "Optional delivery note for the target digital employee", - }, - }, - "required": ["agent_name", "file_path"], - }, - }, - }, { "type": "function", "function": { @@ -849,35 +831,6 @@ }, }, }, - # --- Pages: public HTML hosting --- - { - "type": "function", - "function": { - "name": "publish_page", - "description": "Publish an HTML file from workspace as a public page. Returns a public URL that anyone can access without login. Only .html/.htm files can be published.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path in workspace, e.g. 'workspace/output.html'", - }, - }, - "required": ["path"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_published_pages", - "description": "List all pages published by this agent, showing their public URLs and view counts.", - "parameters": { - "type": "object", - "properties": {}, - }, - }, - }, ] @@ -885,7 +838,6 @@ # DB configuration. _ALWAYS_INCLUDE_CORE = { "send_channel_file", - "send_file_to_agent", "write_file", } # Feishu tools are ONLY included when the agent has a configured Feishu channel, @@ -911,6 +863,7 @@ async def _agent_has_feishu(agent_id: uuid.UUID) -> bool: """Check if agent has a configured Feishu channel.""" try: from app.models.channel_config import ChannelConfig + async with async_session() as db: r = await db.execute( select(ChannelConfig).where( @@ -926,9 +879,10 @@ async def _agent_has_feishu(agent_id: uuid.UUID) -> bool: # ─── Dynamic Tool Loading from DB ────────────────────────────── + async def get_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: """Load enabled tools for an agent from DB (OpenAI function-calling format). - + Falls back to hardcoded AGENT_TOOLS if DB not ready. Always includes core system tools (send_channel_file, write_file). Feishu tools are only included when the agent has a configured Feishu channel. @@ -988,6 +942,7 @@ async def get_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: # ─── Workspace initialization ────────────────────────────────── + async def ensure_workspace(agent_id: uuid.UUID, tenant_id: str | None = None) -> Path: """Initialize agent workspace with standard structure.""" ws = WORKSPACE_ROOT / str(agent_id) @@ -1009,21 +964,28 @@ async def ensure_workspace(agent_id: uuid.UUID, tenant_id: str | None = None) -> # Create default company profile if missing profile_path = enterprise_dir / "company_profile.md" if not profile_path.exists(): - profile_path.write_text("# Company Profile\n\n_Edit company information here. All digital employees can access this._\n\n## Basic Info\n- Company Name:\n- Industry:\n- Founded:\n\n## Business Overview\n\n## Organization Structure\n\n## Company Culture\n", encoding="utf-8") + profile_path.write_text( + "# Company Profile\n\n_Edit company information here. All digital employees can access this._\n\n## Basic Info\n- Company Name:\n- Industry:\n- Founded:\n\n## Business Overview\n\n## Organization Structure\n\n## Company Culture\n", + encoding="utf-8", + ) # Migrate: move root-level memory.md into memory/ directory if (ws / "memory.md").exists() and not (ws / "memory" / "memory.md").exists(): import shutil + shutil.move(str(ws / "memory.md"), str(ws / "memory" / "memory.md")) # Create default memory file if missing if not (ws / "memory" / "memory.md").exists(): - (ws / "memory" / "memory.md").write_text("# Memory\n\n_Record important information and knowledge here._\n", encoding="utf-8") + (ws / "memory" / "memory.md").write_text( + "# Memory\n\n_Record important information and knowledge here._\n", encoding="utf-8" + ) if not (ws / "soul.md").exists(): # Try to load from DB try: from app.models.agent import Agent + async with async_session() as db: r = await db.execute(select(Agent).where(Agent.id == agent_id)) agent = r.scalar_one_or_none() @@ -1033,9 +995,13 @@ async def ensure_workspace(agent_id: uuid.UUID, tenant_id: str | None = None) -> encoding="utf-8", ) else: - (ws / "soul.md").write_text("# Personality\n\n_Describe your role and responsibilities._\n", encoding="utf-8") + (ws / "soul.md").write_text( + "# Personality\n\n_Describe your role and responsibilities._\n", encoding="utf-8" + ) except Exception: - (ws / "soul.md").write_text("# Personality\n\n_Describe your role and responsibilities._\n", encoding="utf-8") + (ws / "soul.md").write_text( + "# Personality\n\n_Describe your role and responsibilities._\n", encoding="utf-8" + ) # Always sync tasks from DB await _sync_tasks_to_file(agent_id, ws) @@ -1047,21 +1013,21 @@ async def _sync_tasks_to_file(agent_id: uuid.UUID, ws: Path): """Sync tasks from DB to tasks.json in workspace.""" try: async with async_session() as db: - result = await db.execute( - select(Task).where(Task.agent_id == agent_id).order_by(Task.created_at.desc()) - ) + result = await db.execute(select(Task).where(Task.agent_id == agent_id).order_by(Task.created_at.desc())) tasks = result.scalars().all() task_list = [] for t in tasks: - task_list.append({ - "title": t.title, - "status": t.status, - "priority": t.priority, - "description": t.description or "", - "created_at": t.created_at.isoformat() if t.created_at else "", - "completed_at": t.completed_at.isoformat() if t.completed_at else "", - }) + task_list.append( + { + "title": t.title, + "status": t.status, + "priority": t.priority, + "description": t.description or "", + "created_at": t.created_at.isoformat() if t.created_at else "", + "completed_at": t.completed_at.isoformat() if t.completed_at else "", + } + ) (ws / "tasks.json").write_text( json.dumps(task_list, ensure_ascii=False, indent=2), @@ -1079,26 +1045,11 @@ async def _sync_tasks_to_file(agent_id: uuid.UUID, ws: Path): "delete_file": "delete_files", "send_feishu_message": "send_feishu_message", "send_message_to_agent": "send_feishu_message", - "send_file_to_agent": "send_feishu_message", "web_search": "web_search", "execute_code": "execute_code", } -async def _get_agent_tenant_id(agent_id: uuid.UUID) -> str | None: - """Get the agent tenant ID for tenant-scoped shared paths.""" - try: - from app.models.agent import Agent - async with async_session() as db: - r = await db.execute(select(Agent.tenant_id).where(Agent.id == agent_id)) - tenant_id = r.scalar_one_or_none() - if tenant_id: - return str(tenant_id) - except Exception: - pass - return None - - async def _execute_tool_direct( tool_name: str, arguments: dict, @@ -1109,8 +1060,7 @@ async def _execute_tool_direct( Used by the approval post-processing hook after an action has been approved and needs to actually run. """ - _agent_tenant_id = await _get_agent_tenant_id(agent_id) - ws = await ensure_workspace(agent_id, tenant_id=_agent_tenant_id) + ws = await ensure_workspace(agent_id) try: if tool_name == "delete_file": return _delete_file(ws, arguments.get("path", "")) @@ -1119,9 +1069,9 @@ async def _execute_tool_direct( content = arguments.get("content", "") if not path: return "Missing path" - return _write_file(ws, path, content, tenant_id=_agent_tenant_id) + return _write_file(ws, path, content) elif tool_name == "execute_code": - return await _execute_code(ws, arguments) + return await _execute_code(agent_id, ws, arguments) elif tool_name == "web_search": return await _web_search(arguments) elif tool_name == "jina_search": @@ -1130,8 +1080,6 @@ async def _execute_tool_direct( return await _send_feishu_message(agent_id, arguments) elif tool_name == "send_message_to_agent": return await _send_message_to_agent(agent_id, arguments) - elif tool_name == "send_file_to_agent": - return await _send_file_to_agent(agent_id, ws, arguments) else: return f"Tool {tool_name} does not support post-approval execution" except Exception as e: @@ -1145,7 +1093,20 @@ async def execute_tool( user_id: uuid.UUID, ) -> str: """Execute a tool call and return the result as a string.""" - _agent_tenant_id = await _get_agent_tenant_id(agent_id) + # Look up agent's tenant_id for tenant-scoped operations + _agent_tenant_id = None + try: + from app.models.agent import Agent as _Ag + from app.database import async_session as _ases + from sqlalchemy import select as _ssel + + async with _ases() as _tdb: + _ag = await _tdb.execute(_ssel(_Ag.tenant_id).where(_Ag.id == agent_id)) + _tid = _ag.scalar_one_or_none() + if _tid: + _agent_tenant_id = str(_tid) + except Exception: + pass ws = await ensure_workspace(agent_id, tenant_id=_agent_tenant_id) @@ -1155,12 +1116,16 @@ async def execute_tool( try: from app.services.autonomy_service import autonomy_service from app.models.agent import Agent as AgentModel + async with async_session() as _adb: _ar = await _adb.execute(select(AgentModel).where(AgentModel.id == agent_id)) _agent = _ar.scalar_one_or_none() if _agent: result_check = await autonomy_service.check_and_enforce( - _adb, _agent, action_type, {"tool": tool_name, "args": str(arguments)[:200], "requested_by": str(user_id)} + _adb, + _agent, + action_type, + {"tool": tool_name, "args": str(arguments)[:200], "requested_by": str(user_id)}, ) await _adb.commit() if not result_check.get("allowed"): @@ -1193,7 +1158,7 @@ async def execute_tool( return "❌ Missing required argument 'path' for write_file. Please provide a file path like 'skills/my-skill/SKILL.md'" if content is None: return "❌ Missing required argument 'content' for write_file" - result = _write_file(ws, path, content, tenant_id=_agent_tenant_id) + result = _write_file(ws, path, content) elif tool_name == "delete_file": result = _delete_file(ws, arguments.get("path", "")) elif tool_name == "manage_tasks": @@ -1212,8 +1177,6 @@ async def execute_tool( result = await _send_web_message(agent_id, arguments) elif tool_name == "send_message_to_agent": result = await _send_message_to_agent(agent_id, arguments) - elif tool_name == "send_file_to_agent": - result = await _send_file_to_agent(agent_id, ws, arguments) elif tool_name == "send_channel_file": result = await _send_channel_file(agent_id, ws, arguments) elif tool_name == "web_search": @@ -1265,11 +1228,6 @@ async def execute_tool( # ── Email Tools ── elif tool_name in ("send_email", "read_emails", "reply_email"): result = await _handle_email_tool(tool_name, agent_id, ws, arguments) - # ── Pages: public HTML hosting ── - elif tool_name == "publish_page": - result = await _publish_page(agent_id, user_id, ws, arguments) - elif tool_name == "list_published_pages": - result = await _list_published_pages(agent_id) else: # Try MCP tool execution result = await _execute_mcp_tool(tool_name, arguments, agent_id=agent_id) @@ -1277,14 +1235,21 @@ async def execute_tool( # Log tool call activity (skip noisy read operations) if tool_name not in ("list_files", "read_file", "read_document"): from app.services.activity_logger import log_activity + await log_activity( - agent_id, "tool_call", + agent_id, + "tool_call", f"Called tool {tool_name}: {result[:80]}", - detail={"tool": tool_name, "args": {k: str(v)[:100] for k, v in arguments.items()}, "result": result[:300]}, + detail={ + "tool": tool_name, + "args": {k: str(v)[:100] for k, v in arguments.items()}, + "result": result[:300], + }, ) return result except Exception as e: import traceback + traceback.print_exc() return f"Tool execution error ({tool_name}): {type(e).__name__}: {str(e)[:200]}" @@ -1302,6 +1267,7 @@ async def _web_search(arguments: dict) -> str: config = {} try: from app.models.tool import Tool + async with async_session() as db: r = await db.execute(select(Tool).where(Tool.name == "web_search")) tool = r.scalar_one_or_none() @@ -1344,13 +1310,15 @@ async def _search_duckduckgo(query: str, max_results: int) -> str: blocks = re.findall( r']*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?).*?' r']*class="result__snippet"[^>]*>(.*?)', - resp.text, re.DOTALL, + resp.text, + re.DOTALL, ) for url, title, snippet in blocks[:max_results]: - title = re.sub(r'<[^>]+>', '', title).strip() - snippet = re.sub(r'<[^>]+>', '', snippet).strip() + title = re.sub(r"<[^>]+>", "", title).strip() + snippet = re.sub(r"<[^>]+>", "", snippet).strip() if "uddg=" in url: from urllib.parse import unquote, parse_qs, urlparse + parsed = parse_qs(urlparse(url).query) url = unquote(parsed.get("uddg", [url])[0]) results.append(f"**{title}**\n{url}\n{snippet}") @@ -1359,12 +1327,14 @@ async def _search_duckduckgo(query: str, max_results: int) -> str: return f'🔍 No results found for "{query}"' return f'🔍 DuckDuckGo results for "{query}" ({len(results)} items):\n\n' + "\n\n---\n\n".join(results) + async def _get_jina_api_key() -> str: """Read Jina API key from DB system_settings first, then fall back to env.""" try: from app.database import async_session from app.models.system_settings import SystemSetting from sqlalchemy import select + async with async_session() as db: result = await db.execute(select(SystemSetting).where(SystemSetting.key == "jina_api_key")) setting = result.scalar_one_or_none() @@ -1373,6 +1343,7 @@ async def _get_jina_api_key() -> str: except Exception: pass from app.config import get_settings + return get_settings().JINA_API_KEY @@ -1469,7 +1440,6 @@ async def _jina_read(arguments: dict) -> str: return f"❌ Jina Reader error: {str(e)[:300]}" - async def _search_tavily(query: str, api_key: str, max_results: int) -> str: """Search via Tavily API (AI-optimized search).""" import httpx @@ -1546,12 +1516,12 @@ async def _search_bing(query: str, api_key: str, max_results: int, language: str async def _send_channel_file(agent_id: uuid.UUID, ws: Path, arguments: dict) -> str: """Send a file to a person or back to the current channel. - + Priority: - 1. If member_name is provided, resolve the recipient across all configured channels + 1. If channel_file_sender ContextVar is set (channel-initiated), use it directly. + 2. If member_name is provided, resolve the recipient across all configured channels and deliver via the appropriate one (Feishu, Slack, etc.). - 2. If channel_file_sender ContextVar is set (channel-initiated), use it directly. - 3. Fall back to web chat download URL when no explicit recipient is requested. + 3. Fall back to web chat download URL. """ rel_path = arguments.get("file_path", "").strip() accompany_msg = arguments.get("message", "") @@ -1569,17 +1539,7 @@ async def _send_channel_file(agent_id: uuid.UUID, ws: Path, arguments: dict) -> if not file_path.exists(): return f"Error: File not found: {rel_path}" - # Priority 1: explicit recipient - resolve member across channels - if member_name: - result = await _send_file_to_recipient(agent_id, file_path, member_name, accompany_msg) - if result: - return result - return ( - f"Failed to send file to '{member_name}': recipient not reachable via configured channels. " - "Use send_message_to_agent for digital employees, or omit member_name to return a download link." - ) - - # Priority 2: channel-initiated (ContextVar set by channel webhook handler) + # Priority 1: channel-initiated (ContextVar set by channel webhook handler) sender = channel_file_sender.get() if sender is not None: try: @@ -1588,6 +1548,12 @@ async def _send_channel_file(agent_id: uuid.UUID, ws: Path, arguments: dict) -> except Exception as e: return f"Failed to send file: {e}" + # Priority 2: recipient-aware - resolve member across channels + if member_name: + result = await _send_file_to_recipient(agent_id, file_path, member_name, accompany_msg) + if result: + return result + # Priority 3: Web chat fallback — return download URL aid = channel_web_agent_id.get() or str(agent_id) base_abs = (WORKSPACE_ROOT / str(agent_id)).resolve() @@ -1596,8 +1562,9 @@ async def _send_channel_file(agent_id: uuid.UUID, ws: Path, arguments: dict) -> except ValueError: file_rel = rel_path from app.config import get_settings as _gs + _s = _gs() - base_url = getattr(_s, 'BASE_URL', '').rstrip('/') or '' + base_url = getattr(_s, "BASE_URL", "").rstrip("/") or "" download_url = f"{base_url}/api/agents/{aid}/files/download?path={file_rel}" msg = f"File ready: [{file_path.name}]({download_url})" if accompany_msg: @@ -1609,7 +1576,7 @@ async def _send_file_to_recipient( agent_id: uuid.UUID, file_path: Path, member_name: str, message: str = "" ) -> str | None: """Resolve a recipient by name and send file via their reachable channel. - + Checks Feishu and Slack channels configured for this agent. Returns a result string, or None if no channel found. """ @@ -1617,9 +1584,7 @@ async def _send_file_to_recipient( async with async_session() as db: # Load all channel configs for this agent - result = await db.execute( - select(ChannelConfig).where(ChannelConfig.agent_id == agent_id) - ) + result = await db.execute(select(ChannelConfig).where(ChannelConfig.agent_id == agent_id)) configs = {c.channel_type: c for c in result.scalars().all()} # --- Try Feishu --- @@ -1643,19 +1608,21 @@ async def _resolve_feishu_recipient(agent_id: uuid.UUID, config, member_name: st """Resolve a Feishu recipient by name. Returns (receive_id, id_type) or None.""" # 1. Try feishu_user_search (checks cache, OrgMember, User table) import re as _re + search_result = await _feishu_user_search(agent_id, {"name": member_name}) - - uid_match = _re.search(r'user_id: `([A-Za-z0-9]+)`', search_result) - oid_match = _re.search(r'open_id: `(ou_[A-Za-z0-9]+)`', search_result) - + + uid_match = _re.search(r"user_id: `([A-Za-z0-9]+)`", search_result) + oid_match = _re.search(r"open_id: `(ou_[A-Za-z0-9]+)`", search_result) + if uid_match: return (uid_match.group(1), "user_id") if oid_match: return (oid_match.group(1), "open_id") - + # 2. Try AgentRelationship from app.models.org import AgentRelationship from sqlalchemy.orm import selectinload + async with async_session() as db: result = await db.execute( select(AgentRelationship) @@ -1677,13 +1644,16 @@ async def _send_file_via_feishu(agent_id, config, file_path: Path, member_name: recipient = await _resolve_feishu_recipient(agent_id, config, member_name) if not recipient: return None - + receive_id, id_type = recipient from app.services.feishu_service import feishu_service + try: await feishu_service.upload_and_send_file( - config.app_id, config.app_secret, - receive_id, file_path, + config.app_id, + config.app_secret, + receive_id, + file_path, receive_id_type=id_type, accompany_msg=message, ) @@ -1692,8 +1662,9 @@ async def _send_file_via_feishu(agent_id, config, file_path: Path, member_name: # If upload fails, try sending a download link as fallback import json as _j from app.config import get_settings as _gs + _s = _gs() - base_url = getattr(_s, 'BASE_URL', '').rstrip('/') or '' + base_url = getattr(_s, "BASE_URL", "").rstrip("/") or "" base_abs = (WORKSPACE_ROOT / str(agent_id)).resolve() try: _rel = str(file_path.resolve().relative_to(base_abs)) @@ -1705,11 +1676,15 @@ async def _send_file_via_feishu(agent_id, config, file_path: Path, member_name: if base_url: dl_url = f"{base_url}/api/agents/{agent_id}/files/download?path={_rel}" parts.append(f"{file_path.name}\n{dl_url}") - parts.append(f"File upload failed ({e}). If you need direct file sending, enable im:resource permission in Feishu.") + parts.append( + f"File upload failed ({e}). If you need direct file sending, enable im:resource permission in Feishu." + ) try: await feishu_service.send_message( - config.app_id, config.app_secret, - receive_id, "text", + config.app_id, + config.app_secret, + receive_id, + "text", _j.dumps({"text": "\n\n".join(parts)}, ensure_ascii=False), receive_id_type=id_type, ) @@ -1721,10 +1696,11 @@ async def _send_file_via_feishu(agent_id, config, file_path: Path, member_name: async def _send_file_via_slack(agent_id, config, file_path: Path, member_name: str, message: str) -> str | None: """Send file to a person via Slack DM. Returns result string or None.""" import httpx + bot_token = config.app_secret or "" if not bot_token: return None - + # Resolve Slack user by name try: async with httpx.AsyncClient(timeout=10) as client: @@ -1745,7 +1721,7 @@ async def _send_file_via_slack(agent_id, config, file_path: Path, member_name: s break if not slack_user_id: return None - + # Open a DM channel dm_resp = await client.post( "https://slack.com/api/conversations.open", @@ -1756,7 +1732,7 @@ async def _send_file_via_slack(agent_id, config, file_path: Path, member_name: s if not dm_data.get("ok"): return None channel_id = dm_data["channel"]["id"] - + # Upload file upload_url_resp = await client.post( "https://slack.com/api/files.getUploadURLExternal", @@ -1766,13 +1742,13 @@ async def _send_file_via_slack(agent_id, config, file_path: Path, member_name: s ud = upload_url_resp.json() if not ud.get("ok"): return f"Slack file upload failed: {ud.get('error')}" - await client.post(ud["upload_url"], content=file_path.read_bytes(), - headers={"Content-Type": "application/octet-stream"}) + await client.post( + ud["upload_url"], content=file_path.read_bytes(), headers={"Content-Type": "application/octet-stream"} + ) complete = await client.post( "https://slack.com/api/files.completeUploadExternal", headers={"Authorization": f"Bearer {bot_token}"}, - json={"files": [{"id": ud["file_id"]}], "channel_id": channel_id, - "initial_comment": message or ""}, + json={"files": [{"id": ud["file_id"]}], "channel_id": channel_id, "initial_comment": message or ""}, ) if not complete.json().get("ok"): return f"Slack file upload complete failed: {complete.json().get('error')}" @@ -1827,6 +1803,7 @@ async def _execute_mcp_tool(tool_name: str, arguments: dict, agent_id=None) -> s if not direct_api_key and tool.mcp_server_name == "Atlassian Rovo": try: from app.api.atlassian import get_atlassian_api_key_for_agent + direct_api_key = await get_atlassian_api_key_for_agent(agent_id) except Exception: pass @@ -1837,7 +1814,9 @@ async def _execute_mcp_tool(tool_name: str, arguments: dict, agent_id=None) -> s return f"❌ MCP tool execution error: {str(e)[:200]}" -async def _execute_via_smithery_connect(mcp_url: str, tool_name: str, arguments: dict, config: dict, agent_id=None) -> str: +async def _execute_via_smithery_connect( + mcp_url: str, tool_name: str, arguments: dict, config: dict, agent_id=None +) -> str: """Execute an MCP tool via Smithery Connect API. Uses stored namespace/connection or falls back to creating one. @@ -1848,6 +1827,7 @@ async def _execute_via_smithery_connect(mcp_url: str, tool_name: str, arguments: # Get Smithery API key centrally (from discover_resources/import_mcp_server AgentTool config) from app.services.resource_discovery import _get_smithery_api_key + api_key = await _get_smithery_api_key(agent_id) if not api_key: return ( @@ -1866,6 +1846,7 @@ async def _execute_via_smithery_connect(mcp_url: str, tool_name: str, arguments: # Fallback: try to get from Smithery settings try: from app.models.tool import Tool + async with async_session() as db: r = await db.execute(select(Tool).where(Tool.name == "discover_resources")) disc_tool = r.scalar_one_or_none() @@ -1905,9 +1886,7 @@ async def _execute_via_smithery_connect(mcp_url: str, tool_name: str, arguments: # Detect auth/connection failures and attempt auto-recovery if tool_resp.status_code in (401, 403, 404): - recovery_result = await _smithery_auto_recover( - api_key, mcp_url, namespace, connection_id, agent_id - ) + recovery_result = await _smithery_auto_recover(api_key, mcp_url, namespace, connection_id, agent_id) if recovery_result: return recovery_result # If recovery returned None, fall through to normal parsing @@ -1939,9 +1918,7 @@ async def _execute_via_smithery_connect(mcp_url: str, tool_name: str, arguments: # Check if error indicates auth/connection issue auth_keywords = ["auth", "unauthorized", "forbidden", "expired", "not found", "connection"] if any(kw in msg.lower() for kw in auth_keywords): - recovery_result = await _smithery_auto_recover( - api_key, mcp_url, namespace, connection_id, agent_id - ) + recovery_result = await _smithery_auto_recover(api_key, mcp_url, namespace, connection_id, agent_id) if recovery_result: return recovery_result return f"❌ MCP tool error: {msg[:300]}" @@ -1971,7 +1948,9 @@ async def _execute_via_smithery_connect(mcp_url: str, tool_name: str, arguments: return f"❌ Smithery Connect error: {str(e)[:200]}" -async def _smithery_auto_recover(api_key: str, mcp_url: str, namespace: str, connection_id: str, agent_id=None) -> str | None: +async def _smithery_auto_recover( + api_key: str, mcp_url: str, namespace: str, connection_id: str, agent_id=None +) -> str | None: """Attempt to auto-recover a failed Smithery connection. Re-creates the Smithery Connect connection. If OAuth is needed, @@ -1979,13 +1958,14 @@ async def _smithery_auto_recover(api_key: str, mcp_url: str, namespace: str, con """ try: from app.services.resource_discovery import _ensure_smithery_connection + display_name = connection_id.replace("-", " ").title() if connection_id else "MCP Server" conn_result = await _ensure_smithery_connection(api_key, mcp_url, display_name) if "error" in conn_result: return ( f"❌ MCP tool connection expired and auto-recovery failed: {conn_result['error']}\n\n" - f"💡 Please re-authorize by telling me: `import_mcp_server(server_id=\"...\", reauthorize=true)`" + f'💡 Please re-authorize by telling me: `import_mcp_server(server_id="...", reauthorize=true)`' ) # Update stored config with new connection info @@ -1996,11 +1976,10 @@ async def _smithery_auto_recover(api_key: str, mcp_url: str, namespace: str, con if agent_id: try: from app.models.tool import Tool, AgentTool + async with async_session() as db: # Update all MCP tools for this server URL - r = await db.execute( - select(Tool).where(Tool.mcp_server_url == mcp_url, Tool.type == "mcp") - ) + r = await db.execute(select(Tool).where(Tool.mcp_server_url == mcp_url, Tool.type == "mcp")) for tool in r.scalars().all(): at_r = await db.execute( select(AgentTool).where( @@ -2038,7 +2017,7 @@ def _list_files(ws: Path, rel_path: str, tenant_id: str | None = None) -> str: else: enterprise_root = (WORKSPACE_ROOT / "enterprise_info").resolve() # Remap: enterprise_info/... → enterprise_info_{tenant_id}/... - sub = rel_path[len("enterprise_info"):].lstrip("/") + sub = rel_path[len("enterprise_info") :].lstrip("/") target = (enterprise_root / sub).resolve() if sub else enterprise_root if not str(target).startswith(str(enterprise_root)): return "Access denied for this path" @@ -2076,7 +2055,7 @@ def _list_files(ws: Path, rel_path: str, tenant_id: str | None = None) -> str: if size_bytes < 1024: size_str = f"{size_bytes}B" else: - size_str = f"{size_bytes/1024:.1f}KB" + size_str = f"{size_bytes / 1024:.1f}KB" items.append(f" 📄 {p.name} ({size_str})") if not items: @@ -2093,7 +2072,7 @@ def _read_file(ws: Path, rel_path: str, tenant_id: str | None = None) -> str: enterprise_root = (WORKSPACE_ROOT / f"enterprise_info_{tenant_id}").resolve() else: enterprise_root = (WORKSPACE_ROOT / "enterprise_info").resolve() - sub = rel_path[len("enterprise_info"):].lstrip("/") + sub = rel_path[len("enterprise_info") :].lstrip("/") file_path = (enterprise_root / sub).resolve() if sub else enterprise_root if not str(file_path).startswith(str(enterprise_root)): return "Access denied for this path" @@ -2122,7 +2101,7 @@ async def _read_document(ws: Path, rel_path: str, max_chars: int = 8000, tenant_ enterprise_root = (WORKSPACE_ROOT / f"enterprise_info_{tenant_id}").resolve() else: enterprise_root = (WORKSPACE_ROOT / "enterprise_info").resolve() - sub = rel_path[len("enterprise_info"):].lstrip("/") + sub = rel_path[len("enterprise_info") :].lstrip("/") file_path = (enterprise_root / sub).resolve() if sub else enterprise_root if not str(file_path).startswith(str(enterprise_root)): return "Access denied for this path" @@ -2138,17 +2117,19 @@ async def _read_document(ws: Path, rel_path: str, max_chars: int = 8000, tenant_ try: if ext == ".pdf": import pdfplumber + text_parts = [] with pdfplumber.open(str(file_path)) as pdf: for i, page in enumerate(pdf.pages[:50]): # Limit to 50 pages page_text = page.extract_text() or "" if page_text: - text_parts.append(f"--- Page {i+1} ---\n{page_text}") + text_parts.append(f"--- Page {i + 1} ---\n{page_text}") content = "\n\n".join(text_parts) if text_parts else "(PDF is empty or text extraction failed)" elif ext == ".docx": from docx import Document from docx.oxml.ns import qn + doc = Document(str(file_path)) lines: list[str] = [] @@ -2198,6 +2179,7 @@ def _extract_table(table) -> str: elif ext == ".xlsx": from openpyxl import load_workbook + wb = load_workbook(str(file_path), read_only=True, data_only=True) sheets = [] for ws_name in wb.sheetnames[:10]: # Limit to 10 sheets @@ -2214,6 +2196,7 @@ def _extract_table(table) -> str: elif ext == ".pptx": from pptx import Presentation + prs = Presentation(str(file_path)) slides = [] for i, slide in enumerate(prs.slides[:50]): @@ -2222,7 +2205,7 @@ def _extract_table(table) -> str: if hasattr(shape, "text") and shape.text.strip(): texts.append(shape.text) if texts: - slides.append(f"--- Slide {i+1} ---\n" + "\n".join(texts)) + slides.append(f"--- Slide {i + 1} ---\n" + "\n".join(texts)) content = "\n\n".join(slides) if slides else "(PPT is empty)" elif ext in (".txt", ".md", ".json", ".csv", ".log"): @@ -2241,27 +2224,14 @@ def _extract_table(table) -> str: return f"Document read failed: {str(e)[:200]}" -def _write_file(ws: Path, rel_path: str, content: str, tenant_id: str | None = None) -> str: +def _write_file(ws: Path, rel_path: str, content: str) -> str: # Protect tasks.json from direct writes if rel_path.strip("/") == "tasks.json": return "tasks.json is read-only. Use manage_tasks tool to manage tasks." - # Handle enterprise_info/ as shared directory (tenant-scoped) - if rel_path and rel_path.startswith("enterprise_info"): - if tenant_id: - enterprise_root = (WORKSPACE_ROOT / f"enterprise_info_{tenant_id}").resolve() - else: - enterprise_root = (WORKSPACE_ROOT / "enterprise_info").resolve() - sub = rel_path[len("enterprise_info"):].lstrip("/") - if not sub: - return "Write failed: please provide a file path under enterprise_info/, e.g. enterprise_info/knowledge_base/report.md" - file_path = (enterprise_root / sub).resolve() - if not str(file_path).startswith(str(enterprise_root)): - return "Access denied for this path" - else: - file_path = (ws / rel_path).resolve() - if not str(file_path).startswith(str(ws.resolve())): - return "Access denied for this path" + file_path = (ws / rel_path).resolve() + if not str(file_path).startswith(str(ws.resolve())): + return "Access denied for this path" try: file_path.parent.mkdir(parents=True, exist_ok=True) @@ -2285,6 +2255,7 @@ def _delete_file(ws: Path, rel_path: str) -> str: try: if file_path.is_dir(): import shutil + shutil.rmtree(file_path) return f"✅ Deleted directory {rel_path}" else: @@ -2330,20 +2301,19 @@ async def _manage_tasks( # Trigger auto-execution for todo tasks import asyncio from app.services.task_executor import execute_task + asyncio.create_task(execute_task(task.id, agent_id)) await _sync_tasks_to_file(agent_id, ws) return f"✅ Task created: {title} — auto-execution started" else: # Supervision task — reminder engine will pick it up - target = args.get('supervision_target_name', 'someone') - schedule = args.get('remind_schedule', 'not set') + target = args.get("supervision_target_name", "someone") + schedule = args.get("remind_schedule", "not set") await _sync_tasks_to_file(agent_id, ws) return f"✅ Supervision task created: '{title}' — will remind {target} on schedule ({schedule})" elif action == "update_status": - result = await db.execute( - select(Task).where(Task.agent_id == agent_id, Task.title.ilike(f"%{title}%")) - ) + result = await db.execute(select(Task).where(Task.agent_id == agent_id, Task.title.ilike(f"%{title}%"))) task = result.scalars().first() if not task: return f"No task found matching '{title}'" @@ -2357,9 +2327,8 @@ async def _manage_tasks( elif action == "delete": from sqlalchemy import delete as sa_delete - result = await db.execute( - select(Task).where(Task.agent_id == agent_id, Task.title.ilike(f"%{title}%")) - ) + + result = await db.execute(select(Task).where(Task.agent_id == agent_id, Task.title.ilike(f"%{title}%"))) task = result.scalars().first() if not task: return f"No task found matching '{title}'" @@ -2395,17 +2364,22 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: # ── Shortcut: if caller provided user_id or open_id directly ── if (direct_user_id or direct_open_id) and not member_name: config_result = await db.execute( - select(ChannelConfig).where(ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "feishu") + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "feishu" + ) ) config = config_result.scalar_one_or_none() if not config: return "❌ This agent has no Feishu channel configured" import json as _j + # Prefer user_id over open_id if direct_user_id: resp = await feishu_service.send_message( - config.app_id, config.app_secret, - receive_id=direct_user_id, msg_type="text", + config.app_id, + config.app_secret, + receive_id=direct_user_id, + msg_type="text", content=_j.dumps({"text": message_text}, ensure_ascii=False), receive_id_type="user_id", ) @@ -2414,8 +2388,10 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: # Fallback to open_id if user_id fails if direct_open_id: resp = await feishu_service.send_message( - config.app_id, config.app_secret, - receive_id=direct_open_id, msg_type="text", + config.app_id, + config.app_secret, + receive_id=direct_open_id, + msg_type="text", content=_j.dumps({"text": message_text}, ensure_ascii=False), receive_id_type="open_id", ) @@ -2424,8 +2400,10 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: return f"❌ 发送失败:{resp.get('msg')} (code {resp.get('code')})" else: resp = await feishu_service.send_message( - config.app_id, config.app_secret, - receive_id=direct_open_id, msg_type="text", + config.app_id, + config.app_secret, + receive_id=direct_open_id, + msg_type="text", content=_j.dumps({"text": message_text}, ensure_ascii=False), receive_id_type="open_id", ) @@ -2452,8 +2430,9 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: _search_result = await _feishu_user_search(agent_id, {"name": member_name}) # Prefer user_id over open_id import re as _re_oid - _uid_match = _re_oid.search(r'user_id: `([A-Za-z0-9]+)`', _search_result) - _oid_match = _re_oid.search(r'open_id: `(ou_[A-Za-z0-9]+)`', _search_result) + + _uid_match = _re_oid.search(r"user_id: `([A-Za-z0-9]+)`", _search_result) + _oid_match = _re_oid.search(r"open_id: `(ou_[A-Za-z0-9]+)`", _search_result) _found_id = None _found_id_type = None if _uid_match: @@ -2464,15 +2443,20 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: _found_id_type = "open_id" if _found_id: config_result = await db.execute( - select(ChannelConfig).where(ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "feishu") + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "feishu" + ) ) config = config_result.scalar_one_or_none() if not config: return "❌ This agent has no Feishu channel configured" import json as _j2 + resp = await feishu_service.send_message( - config.app_id, config.app_secret, - receive_id=_found_id, msg_type="text", + config.app_id, + config.app_secret, + receive_id=_found_id, + msg_type="text", content=_j2.dumps({"text": message_text}, ensure_ascii=False), receive_id_type=_found_id_type, ) @@ -2487,7 +2471,12 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: f"通讯录搜索结果:{_search_result[:200]}" ) - if not target_member.feishu_user_id and not target_member.feishu_open_id and not target_member.email and not target_member.phone: + if ( + not target_member.feishu_user_id + and not target_member.feishu_open_id + and not target_member.email + and not target_member.phone + ): return f"❌ {member_name} has no linked Feishu account (no user_id, open_id, email, or phone)" # Get the agent's Feishu bot credentials @@ -2505,9 +2494,12 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: async def _try_send(app_id: str, app_secret: str, receive_id: str, id_type: str = "open_id") -> dict: return await feishu_service.send_message( - app_id, app_secret, - receive_id=receive_id, msg_type="text", - content=content, receive_id_type=id_type, + app_id, + app_secret, + receive_id=receive_id, + msg_type="text", + content=content, + receive_id_type=id_type, ) async def _save_outgoing_to_feishu_session(open_id: str): @@ -2524,12 +2516,11 @@ async def _save_outgoing_to_feishu_session(open_id: str): # Look up the platform user: prefer feishu_user_id, then feishu_open_id from app.models.user import User as UserModel + feishu_user = None if open_id: # open_id param is contextual, try as user_id first isn't reliable here # Try user lookup by open_id since that's what we have from session context - u_r = await db.execute( - select(UserModel).where(UserModel.feishu_open_id == open_id) - ) + u_r = await db.execute(select(UserModel).where(UserModel.feishu_open_id == open_id)) feishu_user = u_r.scalar_one_or_none() user_id = feishu_user.id if feishu_user else creator_id @@ -2542,13 +2533,15 @@ async def _save_outgoing_to_feishu_session(open_id: str): source_channel="feishu", first_message_title=f"[Agent → {member_name}]", ) - db.add(ChatMessage( - agent_id=agent_id, - user_id=user_id, - role="assistant", - content=message_text, - conversation_id=str(sess.id), - )) + db.add( + ChatMessage( + agent_id=agent_id, + user_id=user_id, + role="assistant", + content=message_text, + conversation_id=str(sess.id), + ) + ) sess.last_message_at = _dt.now(_tz.utc) await db.commit() logger.info(f"[Feishu] Saved outgoing message to session {sess.id} ({member_name})") @@ -2566,7 +2559,8 @@ async def _save_outgoing_to_feishu_session(open_id: str): if target_member.email or target_member.phone: try: resolved = await feishu_service.resolve_open_id( - config.app_id, config.app_secret, + config.app_id, + config.app_secret, email=target_member.email, mobile=target_member.phone, ) @@ -2596,15 +2590,18 @@ async def _save_outgoing_to_feishu_session(open_id: str): # Try user_id with org sync app first if target_member.feishu_user_id: resp2 = await _try_send( - org_setting.value["app_id"], org_setting.value["app_secret"], - target_member.feishu_user_id, "user_id", + org_setting.value["app_id"], + org_setting.value["app_secret"], + target_member.feishu_user_id, + "user_id", ) if resp2.get("code") == 0: await _save_outgoing_to_feishu_session(target_member.feishu_open_id) return f"✅ Successfully sent message to {member_name}" # Fallback to open_id with org sync app resp2 = await _try_send( - org_setting.value["app_id"], org_setting.value["app_secret"], + org_setting.value["app_id"], + org_setting.value["app_secret"], target_member.feishu_open_id, ) if resp2.get("code") == 0: @@ -2636,6 +2633,7 @@ async def _send_web_message(agent_id: uuid.UUID, args: dict) -> str: async with async_session() as db: # Look up target user by username or display_name from sqlalchemy import or_ + u_result = await db.execute( select(UserModel).where( or_( @@ -2653,11 +2651,14 @@ async def _send_web_message(agent_id: uuid.UUID, args: dict) -> str: # Find or create a web session between the agent and this user sess_r = await db.execute( - select(ChatSession).where( + select(ChatSession) + .where( ChatSession.agent_id == agent_id, ChatSession.user_id == target_user.id, ChatSession.source_channel == "web", - ).order_by(ChatSession.created_at.desc()).limit(1) + ) + .order_by(ChatSession.created_at.desc()) + .limit(1) ) session = sess_r.scalar_one_or_none() @@ -2674,28 +2675,33 @@ async def _send_web_message(agent_id: uuid.UUID, args: dict) -> str: await db.flush() # Save the message - db.add(ChatMessage( - agent_id=agent_id, - user_id=target_user.id, - role="assistant", - content=message_text, - conversation_id=str(session.id), - )) + db.add( + ChatMessage( + agent_id=agent_id, + user_id=target_user.id, + role="assistant", + content=message_text, + conversation_id=str(session.id), + ) + ) session.last_message_at = _dt.now(_tz.utc) await db.commit() # Push via WebSocket if user has an active connection try: from app.api.websocket import manager as ws_manager + agent_id_str = str(agent_id) if agent_id_str in ws_manager.active_connections: for ws, sid in list(ws_manager.active_connections[agent_id_str]): try: - await ws.send_json({ - "type": "trigger_notification", - "content": message_text, - "triggers": ["web_message"], - }) + await ws.send_json( + { + "type": "trigger_notification", + "content": message_text, + "triggers": ["web_message"], + } + ) except Exception: pass except Exception: @@ -2708,163 +2714,6 @@ async def _send_web_message(agent_id: uuid.UUID, args: dict) -> str: return f"❌ Web message send error: {str(e)[:200]}" -async def _send_file_to_agent(from_agent_id: uuid.UUID, ws: Path, args: dict) -> str: - """Send a workspace file to another digital employee (agent).""" - agent_name = (args.get("agent_name") or "").strip() - rel_path = (args.get("file_path") or "").strip() - delivery_note = (args.get("message") or "").strip() - - if not agent_name or not rel_path: - return "❌ Please provide both agent_name and file_path" - - # Resolve source file path inside sender workspace - source_file_path = (ws / rel_path).resolve() - ws_resolved = ws.resolve() - sender_root = (WORKSPACE_ROOT / str(from_agent_id)).resolve() - if not str(source_file_path).startswith(str(ws_resolved)): - source_file_path = (sender_root / rel_path).resolve() - if not str(source_file_path).startswith(str(sender_root)): - return "❌ Access denied: source path is outside your workspace" - - if not source_file_path.exists(): - return f"❌ Source file not found: {rel_path}" - if not source_file_path.is_file(): - return f"❌ Source path is not a file: {rel_path}" - - # File size limit (50 MB) - MAX_FILE_SIZE = 50 * 1024 * 1024 - file_size = source_file_path.stat().st_size - if file_size > MAX_FILE_SIZE: - size_mb = file_size / (1024 * 1024) - return f"❌ File too large ({size_mb:.1f} MB). Maximum allowed is 50 MB." - - try: - from app.models.agent import Agent - from app.services.activity_logger import log_activity - import shutil - - async with async_session() as db: - src_result = await db.execute(select(Agent).where(Agent.id == from_agent_id)) - source_agent = src_result.scalar_one_or_none() - source_name = source_agent.name if source_agent else "Unknown agent" - source_tenant_id = source_agent.tenant_id if source_agent else None - - # Build base filter: same tenant + not self - base_filter = [Agent.id != from_agent_id] - if source_tenant_id: - base_filter.append(Agent.tenant_id == source_tenant_id) - - # Try exact name match first, then fuzzy - target_agent = None - exact_result = await db.execute( - select(Agent).where(Agent.name == agent_name, *base_filter) - ) - target_agent = exact_result.scalars().first() - if not target_agent: - # Sanitize SQL wildcards in user input - safe_name = agent_name.replace("%", "").replace("_", "\_") - fuzzy_result = await db.execute( - select(Agent).where(Agent.name.ilike(f"%{safe_name}%"), *base_filter) - ) - target_agent = fuzzy_result.scalars().first() - if not target_agent: - all_r = await db.execute(select(Agent).where(*base_filter)) - names = [a.name for a in all_r.scalars().all()] - return f"❌ No agent found matching '{agent_name}'. Available: {', '.join(names) if names else 'none'}" - - if target_agent.is_expired or (target_agent.expires_at and datetime.now(timezone.utc) >= target_agent.expires_at): - return f"⚠️ {target_agent.name} is currently unavailable — their service period has ended. Please contact the platform administrator." - - target_tenant_id = str(target_agent.tenant_id) if target_agent.tenant_id else None - target_name = target_agent.name - target_id = target_agent.id - - target_ws = await ensure_workspace(target_id, tenant_id=target_tenant_id) - inbox_dir = (target_ws / "workspace" / "inbox").resolve() - files_dir = (inbox_dir / "files").resolve() - target_ws_resolved = target_ws.resolve() - if not str(inbox_dir).startswith(str(target_ws_resolved)) or not str(files_dir).startswith(str(target_ws_resolved)): - return "❌ Access denied for target agent inbox path" - - inbox_dir.mkdir(parents=True, exist_ok=True) - files_dir.mkdir(parents=True, exist_ok=True) - - ts = datetime.now(timezone.utc) - stamp = ts.strftime("%Y%m%d_%H%M%S_%f") - delivered_name = source_file_path.name - delivered_path = files_dir / delivered_name - while delivered_path.exists(): - delivered_name = f"{stamp}_{source_file_path.name}" - delivered_path = files_dir / delivered_name - - shutil.copy2(source_file_path, delivered_path) - - sender_short = str(from_agent_id)[:8] - note_path = inbox_dir / f"{stamp}_{sender_short}_file_delivery.md" - target_rel_path = f"workspace/inbox/files/{delivered_name}" - note_lines = [ - f"# File delivery from {source_name}", - "", - f"- Time (UTC): {ts.isoformat()}", - f"- Sender: {source_name}", - f"- Source path: {rel_path}", - f"- Delivered file: {target_rel_path}", - "", - ] - if delivery_note: - note_lines.append("## Note") - note_lines.append(delivery_note) - note_lines.append("") - note_lines.append("## Action") - note_lines.append(f"- Read the file via `read_file(path=\"{target_rel_path}\")`") - note_path.write_text("\n".join(note_lines), encoding="utf-8") - - from app.models.audit import AuditLog - async with async_session() as db: - db.add(AuditLog( - agent_id=from_agent_id, - action="collaboration:file_send", - details={ - "to_agent": str(target_id), - "to_agent_name": target_name, - "source_file": rel_path, - "delivered_file": target_rel_path, - }, - )) - db.add(AuditLog( - agent_id=target_id, - action="collaboration:file_receive", - details={ - "from_agent": str(from_agent_id), - "from_agent_name": source_name, - "source_file": rel_path, - "delivered_file": target_rel_path, - }, - )) - await db.commit() - - await log_activity( - from_agent_id, - "agent_file_sent", - f"Sent file to {target_name}", - detail={"target_agent": target_name, "source_file": rel_path, "delivered_file": target_rel_path}, - ) - await log_activity( - target_id, - "agent_file_received", - f"Received file from {source_name}", - detail={"source_agent": source_name, "source_file": rel_path, "delivered_file": target_rel_path}, - ) - - return ( - f"✅ File sent to {target_name}.\n" - f"- Delivered to: {target_rel_path}\n" - f"- Inbox note: workspace/inbox/{note_path.name}" - ) - except Exception as e: - return f"❌ Agent file send error: {str(e)[:200]}" - - async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: """Send a message to another digital employee. Uses a single request-response pattern: the source agent sends a message, the target agent replies once, and the result is returned. @@ -2888,27 +2737,14 @@ async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: src_result = await db.execute(select(Agent).where(Agent.id == from_agent_id)) source_agent = src_result.scalar_one_or_none() source_name = source_agent.name if source_agent else "Unknown agent" - source_tenant_id = source_agent.tenant_id if source_agent else None - - # Build base filter: same tenant + not self - base_filter = [Agent.id != from_agent_id] - if source_tenant_id: - base_filter.append(Agent.tenant_id == source_tenant_id) - # Find target agent by name — exact match first, then fuzzy - target = None - exact_result = await db.execute( - select(Agent).where(Agent.name == agent_name, *base_filter) + # Find target agent by name + result = await db.execute( + select(Agent).where(Agent.name.ilike(f"%{agent_name}%"), Agent.id != from_agent_id) ) - target = exact_result.scalars().first() + target = result.scalars().first() if not target: - safe_name = agent_name.replace("%", "").replace("_", "\_") - fuzzy_result = await db.execute( - select(Agent).where(Agent.name.ilike(f"%{safe_name}%"), *base_filter) - ) - target = fuzzy_result.scalars().first() - if not target: - all_r = await db.execute(select(Agent).where(*base_filter)) + all_r = await db.execute(select(Agent).where(Agent.id != from_agent_id)) names = [a.name for a in all_r.scalars().all()] return f"❌ No agent found matching '{agent_name}'. Available: {', '.join(names) if names else 'none'}" @@ -2919,6 +2755,7 @@ async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: # ── OpenClaw target: queue message for gateway poll ── if getattr(target, "agent_type", "native") == "openclaw": from app.models.gateway_message import GatewayMessage as GMsg + gw_msg = GMsg( agent_id=target.id, sender_agent_id=from_agent_id, @@ -2928,12 +2765,19 @@ async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: ) db.add(gw_msg) await db.commit() - online = target.openclaw_last_seen and (datetime.now(timezone.utc) - target.openclaw_last_seen).total_seconds() < 300 + online = ( + target.openclaw_last_seen + and (datetime.now(timezone.utc) - target.openclaw_last_seen).total_seconds() < 300 + ) status_hint = "online" if online else "offline (message will be delivered on next heartbeat)" return f"✅ Message sent to {target.name} (OpenClaw agent, currently {status_hint}). The message has been queued and will be delivered when the agent polls for updates." - src_part_r = await db.execute(select(Participant).where(Participant.type == "agent", Participant.ref_id == from_agent_id)) + src_part_r = await db.execute( + select(Participant).where(Participant.type == "agent", Participant.ref_id == from_agent_id) + ) src_participant = src_part_r.scalar_one_or_none() - tgt_part_r = await db.execute(select(Participant).where(Participant.type == "agent", Participant.ref_id == target.id)) + tgt_part_r = await db.execute( + select(Participant).where(Participant.type == "agent", Participant.ref_id == target.id) + ) tgt_participant = tgt_part_r.scalar_one_or_none() # Find or create ChatSession for this agent pair (ordered consistently) @@ -2978,7 +2822,9 @@ async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: fb_r = await db.execute(select(LLMModel).where(LLMModel.id == target.fallback_model_id)) target_model = fb_r.scalar_one_or_none() if target_model: - logger.warning(f"[A2A] Primary model unavailable for {target.name}, using fallback: {target_model.model}") + logger.warning( + f"[A2A] Primary model unavailable for {target.name}, using fallback: {target_model.model}" + ) if not target_model: return f"⚠️ {target.name} has no LLM model configured" @@ -3014,28 +2860,27 @@ async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: # Save source message owner_id = source_agent.creator_id if source_agent else from_agent_id - db.add(ChatMessage( - agent_id=session_agent_id, - user_id=owner_id, - role="user", - content=message_text, - conversation_id=session_id, - participant_id=src_participant.id if src_participant else None, - )) + db.add( + ChatMessage( + agent_id=session_agent_id, + user_id=owner_id, + role="user", + content=message_text, + conversation_id=session_id, + participant_id=src_participant.id if src_participant else None, + ) + ) chat_session.last_message_at = datetime.now(timezone.utc) await db.commit() # Call target LLM with tool support (multi-round) - import asyncio - import random - import httpx from app.services.llm_utils import ( get_provider_base_url, create_llm_client, LLMMessage, ) - from app.services.llm_client import LLMError from app.services.agent_tools import get_agent_tools_for_llm, execute_tool + base_url = get_provider_base_url(target_model.provider, target_model.base_url) if not base_url: return f"⚠️ {target.name}'s model has no API base URL configured" @@ -3060,71 +2905,41 @@ async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: base_url=base_url, timeout=120.0, ) - _A2A_RETRYABLE_MARKERS = ( - "http 408", "http 429", "http 500", "http 502", "http 503", "http 504", - "timeout", "timed out", "connection failed", "temporarily unavailable", "rate limit", - ) - _A2A_MAX_RETRIES = 3 - - def _is_retryable_llm_error(exc: Exception) -> bool: - """Determine whether an LLM exception is transient and worth retrying.""" - if isinstance(exc, (httpx.TimeoutException, httpx.TransportError)): - return True - if isinstance(exc, LLMError): - lowered = (str(exc) or "").lower() - return any(m in lowered for m in _A2A_RETRYABLE_MARKERS) - return False - try: for _round in range(max_tool_rounds): - response = None - for attempt in range(1, _A2A_MAX_RETRIES + 1): - try: - response = await llm_client.complete( - messages=full_msgs, - tools=tools_for_llm if tools_for_llm else None, - temperature=0.7, - max_tokens=4096, - ) - break - except Exception as llm_exc: - if not _is_retryable_llm_error(llm_exc) or attempt >= _A2A_MAX_RETRIES: - raise - - err_text = str(llm_exc) or type(llm_exc).__name__ - # Exponential backoff with jitter to prevent thundering herd - backoff = (2 ** (attempt - 1)) + random.uniform(0, 0.5) - logger.warning( - f"[A2A] LLM call failed for {target.name} (round={_round + 1}, " - f"attempt={attempt}/{_A2A_MAX_RETRIES}): {err_text[:200]}. " - f"Retrying in {backoff:.1f}s" - ) - await asyncio.sleep(backoff) - - if response is None: - raise RuntimeError("A2A LLM response is unexpectedly empty after retries") + response = await llm_client.complete( + messages=full_msgs, + tools=tools_for_llm if tools_for_llm else None, + temperature=0.7, + max_tokens=4096, + ) # Track tokens from API response real_tokens = extract_usage_tokens(response.usage) if real_tokens: _a2a_accumulated_tokens += real_tokens else: - round_chars = sum(len(m.content or '') for m in full_msgs if isinstance(m.content, str)) + round_chars = sum(len(m.content or "") for m in full_msgs if isinstance(m.content, str)) _a2a_accumulated_tokens += estimate_tokens_from_chars(round_chars) # Check for tool calls if response.tool_calls: # Add assistant message with tool calls to conversation - full_msgs.append(LLMMessage( - role="assistant", - content=response.content or None, - tool_calls=[{ - "id": tc.get("id", ""), - "type": "function", - "function": tc.get("function", {}), - } for tc in response.tool_calls], - reasoning_content=response.reasoning_content, - )) + full_msgs.append( + LLMMessage( + role="assistant", + content=response.content or None, + tool_calls=[ + { + "id": tc.get("id", ""), + "type": "function", + "function": tc.get("function", {}), + } + for tc in response.tool_calls + ], + reasoning_content=response.reasoning_content, + ) + ) # Execute each tool call for tc in response.tool_calls: @@ -3144,29 +2959,36 @@ def _is_retryable_llm_error(exc: Exception) -> bool: # Save tool_call to DB so it appears in chat history try: async with async_session() as _tc_db: - _tc_db.add(ChatMessage( - agent_id=session_agent_id, - user_id=owner_id, - role="tool_call", - content=json.dumps({ - "name": tool_name, - "args": tool_args, - "status": "done", - "result": str(tool_result)[:500], - }, ensure_ascii=False), - conversation_id=session_id, - participant_id=tgt_participant.id if tgt_participant else None, - )) + _tc_db.add( + ChatMessage( + agent_id=session_agent_id, + user_id=owner_id, + role="tool_call", + content=json.dumps( + { + "name": tool_name, + "args": tool_args, + "status": "done", + "result": str(tool_result)[:500], + }, + ensure_ascii=False, + ), + conversation_id=session_id, + participant_id=tgt_participant.id if tgt_participant else None, + ) + ) await _tc_db.commit() except Exception as _tc_err: logger.error(f"[A2A] Failed to save tool_call: {_tc_err}") # Add tool result to conversation - full_msgs.append(LLMMessage( - role="tool", - tool_call_id=tc.get("id", ""), - content=str(tool_result)[:4000], - )) + full_msgs.append( + LLMMessage( + role="tool", + tool_call_id=tc.get("id", ""), + content=str(tool_result)[:4000], + ) + ) continue # Next LLM round # No tool calls — this is the final text response @@ -3184,27 +3006,34 @@ def _is_retryable_llm_error(exc: Exception) -> bool: # Save target reply async with async_session() as db2: - part_r = await db2.execute(select(Participant).where(Participant.type == "agent", Participant.ref_id == target.id)) + part_r = await db2.execute( + select(Participant).where(Participant.type == "agent", Participant.ref_id == target.id) + ) tgt_part = part_r.scalar_one_or_none() - db2.add(ChatMessage( - agent_id=session_agent_id, - user_id=owner_id, - role="assistant", - content=target_reply, - conversation_id=session_id, - participant_id=tgt_part.id if tgt_part else None, - )) + db2.add( + ChatMessage( + agent_id=session_agent_id, + user_id=owner_id, + role="assistant", + content=target_reply, + conversation_id=session_id, + participant_id=tgt_part.id if tgt_part else None, + ) + ) await db2.commit() # Log activity from app.services.activity_logger import log_activity + await log_activity( - target.id, "agent_msg_sent", + target.id, + "agent_msg_sent", f"Replied to message from {source_name}", detail={"partner": source_name, "message": message_text[:200], "reply": target_reply[:200]}, ) await log_activity( - from_agent_id, "agent_msg_sent", + from_agent_id, + "agent_msg_sent", f"Sent message to {target.name} and received reply", detail={"partner": target.name, "message": message_text[:200], "reply": target_reply[:200]}, ) @@ -3212,24 +3041,16 @@ def _is_retryable_llm_error(exc: Exception) -> bool: return f"💬 {target.name} replied:\n{target_reply}" except Exception as e: - logger.exception( - f"[A2A] send_message_to_agent failed: from={from_agent_id}, to={args.get('agent_name', '')}" - ) - error_type = type(e).__name__ - error_detail = (str(e) or "").strip() - if not error_detail: - timeout_types = {"ReadTimeout", "ConnectTimeout", "TimeoutException"} - if error_type in timeout_types: - error_detail = "LLM request timed out while waiting for target agent response" - else: - error_detail = "No detailed error message returned from upstream" - return f"❌ Message send error ({error_type}): {error_detail[:200]}" + import traceback + traceback.print_exc() + return f"❌ Message send error: {str(e)[:200]}" # Plaza Tools — Agent Square social feed # ═══════════════════════════════════════════════════════ + async def _plaza_get_new_posts(agent_id: uuid.UUID, arguments: dict) -> str: """Get recent posts from the Agent Plaza, scoped to agent's tenant.""" from app.models.plaza import PlazaPost, PlazaComment @@ -3283,7 +3104,7 @@ async def _plaza_create_post(agent_id: uuid.UUID, arguments: dict) -> str: content = arguments.get("content", "").strip() if not content: - return "Error: Post content cannot be empty." + return "❌ Post content cannot be empty." if len(content) > 500: content = content[:500] @@ -3293,7 +3114,7 @@ async def _plaza_create_post(agent_id: uuid.UUID, arguments: dict) -> str: ar = await db.execute(select(AgentModel).where(AgentModel.id == agent_id)) agent = ar.scalar_one_or_none() if not agent: - return "Error: Agent not found." + return "❌ Agent not found." post = PlazaPost( author_id=agent_id, @@ -3303,41 +3124,12 @@ async def _plaza_create_post(agent_id: uuid.UUID, arguments: dict) -> str: tenant_id=agent.tenant_id, ) db.add(post) - await db.flush() # get post.id - - # Extract @mentions - try: - import re - mentions = re.findall(r'@(\S+)', content) - if mentions: - from app.services.notification_service import send_notification - a_q = select(AgentModel).where(AgentModel.id != agent_id) - if agent.tenant_id: - a_q = a_q.where(AgentModel.tenant_id == agent.tenant_id) - a_map = {a.name.lower(): a for a in (await db.execute(a_q)).scalars().all()} - notified = set() - for m in mentions: - ma = a_map.get(m.lower()) - if ma and ma.id not in notified: - notified.add(ma.id) - await send_notification( - db, agent_id=ma.id, - type="mention", - title=f"{agent.name} mentioned you in a plaza post", - body=content[:150], - link=f"/plaza?post={post.id}", - ref_id=post.id, - sender_name=agent.name, - ) - except Exception: - pass - await db.commit() await db.refresh(post) - return f"Post published! (ID: {post.id})" + return f"✅ Post published! (ID: {post.id})" except Exception as e: - return f"Failed to create post: {str(e)[:200]}" + return f"❌ Failed to create post: {str(e)[:200]}" async def _plaza_add_comment(agent_id: uuid.UUID, arguments: dict) -> str: @@ -3348,14 +3140,14 @@ async def _plaza_add_comment(agent_id: uuid.UUID, arguments: dict) -> str: post_id = arguments.get("post_id", "") content = arguments.get("content", "").strip() if not content: - return "Error: Comment content cannot be empty." + return "❌ Comment content cannot be empty." if len(content) > 300: content = content[:300] try: pid = uuid.UUID(str(post_id)) except Exception: - return "Error: Invalid post_id format." + return "❌ Invalid post_id format." try: async with async_session() as db: @@ -3363,13 +3155,13 @@ async def _plaza_add_comment(agent_id: uuid.UUID, arguments: dict) -> str: pr = await db.execute(select(PlazaPost).where(PlazaPost.id == pid)) post = pr.scalar_one_or_none() if not post: - return "Error: Post not found." + return "❌ Post not found." # Get agent name ar = await db.execute(select(AgentModel).where(AgentModel.id == agent_id)) agent = ar.scalar_one_or_none() if not agent: - return "Error: Agent not found." + return "❌ Agent not found." comment = PlazaComment( post_id=pid, @@ -3380,125 +3172,54 @@ async def _plaza_add_comment(agent_id: uuid.UUID, arguments: dict) -> str: ) db.add(comment) post.comments_count = (post.comments_count or 0) + 1 - - # Notify post author (if not self) - if post.author_id != agent_id: - try: - from app.services.notification_service import send_notification - if post.author_type == "agent": - await send_notification( - db, agent_id=post.author_id, - type="plaza_reply", - title=f"{agent.name} commented on your post", - body=content[:150], - link=f"/plaza?post={pid}", - ref_id=pid, - sender_name=agent.name, - ) - # Also notify human creator - pa = (await db.execute(select(AgentModel).where(AgentModel.id == post.author_id))).scalar_one_or_none() - if pa and pa.creator_id: - await send_notification( - db, user_id=pa.creator_id, - type="plaza_comment", - title=f"{agent.name} commented on {pa.name}'s post", - body=content[:100], - link=f"/plaza?post={pid}", - ref_id=pid, - sender_name=agent.name, - ) - elif post.author_type == "human": - await send_notification( - db, user_id=post.author_id, - type="plaza_reply", - title=f"{agent.name} commented on your post", - body=content[:150], - link=f"/plaza?post={pid}", - ref_id=pid, - sender_name=agent.name, - ) - except Exception: - pass - - # Notify other agents who commented on this post - try: - from app.services.notification_service import send_notification - other_crs = await db.execute( - select(PlazaComment.author_id, PlazaComment.author_type) - .where(PlazaComment.post_id == pid) - .distinct() - ) - notified = {post.author_id, agent_id} - for row in other_crs.fetchall(): - cid, ctype = row - if cid in notified: - continue - notified.add(cid) - if ctype == "agent": - await send_notification( - db, agent_id=cid, - type="plaza_reply", - title=f"{agent.name} also commented on a post you commented on", - body=content[:150], - link=f"/plaza?post={pid}", - ref_id=pid, - sender_name=agent.name, - ) - except Exception: - pass - - # Extract @mentions - try: - import re - mentions = re.findall(r'@(\S+)', content) - if mentions: - from app.services.notification_service import send_notification - from app.models.user import User - # Load agents in tenant - a_q = select(AgentModel).where(AgentModel.id != agent_id) - if agent.tenant_id: - a_q = a_q.where(AgentModel.tenant_id == agent.tenant_id) - a_map = {a.name.lower(): a for a in (await db.execute(a_q)).scalars().all()} - notified_m = set() - for m in mentions: - ma = a_map.get(m.lower()) - if ma and ma.id not in notified_m: - notified_m.add(ma.id) - await send_notification( - db, agent_id=ma.id, - type="mention", - title=f"{agent.name} mentioned you in a comment", - body=content[:150], - link=f"/plaza?post={pid}", - ref_id=pid, - sender_name=agent.name, - ) - except Exception: - pass - await db.commit() - return f"Comment added to post by {post.author_name}." + return f"✅ Comment added to post by {post.author_name}." except Exception as e: - return f"Failed to add comment: {str(e)[:200]}" + return f"❌ Failed to add comment: {str(e)[:200]}" # ─── Code Execution ───────────────────────────────────────────── # Dangerous patterns to block _DANGEROUS_BASH = [ - "rm -rf /", "rm -rf ~", "sudo ", "mkfs", "dd if=", - ":(){ :", "chmod 777 /", "chown ", "shutdown", "reboot", - "curl ", "wget ", "nc ", "ncat ", "ssh ", "scp ", - "python3 -c", "python -c", + "rm -rf /", + "rm -rf ~", + "sudo ", + "mkfs", + "dd if=", + ":(){ :", + "chmod 777 /", + "chown ", + "shutdown", + "reboot", + "curl ", + "wget ", + "nc ", + "ncat ", + "ssh ", + "scp ", + "python3 -c", + "python -c", ] _DANGEROUS_PYTHON_IMPORTS = [ - "subprocess", "shutil.rmtree", "os.system", "os.popen", - "os.exec", "os.spawn", - "socket", "http.client", "urllib.request", "requests", - "ftplib", "smtplib", "telnetlib", "ctypes", - "__import__", "importlib", + "subprocess", + "shutil.rmtree", + "os.system", + "os.popen", + "os.exec", + "os.spawn", + "socket", + "http.client", + "urllib.request", + "requests", + "ftplib", + "smtplib", + "telnetlib", + "ctypes", + "__import__", + "importlib", ] @@ -3520,8 +3241,15 @@ def _check_code_safety(language: str, code: str) -> str | None: return f"❌ Blocked: unsafe operation detected ({pattern})" elif language == "node": - dangerous_node = ["child_process", "fs.rmSync", "fs.rmdirSync", "process.exit", - "require('http')", "require('https')", "require('net')"] + dangerous_node = [ + "child_process", + "fs.rmSync", + "fs.rmdirSync", + "process.exit", + "require('http')", + "require('https')", + "require('net')", + ] for pattern in dangerous_node: if pattern.lower() in code_lower: return f"❌ Blocked: unsafe operation detected ({pattern})" @@ -3543,10 +3271,10 @@ async def _execute_code(ws: Path, arguments: dict) -> str: if language not in ("python", "bash", "node"): return f"❌ Unsupported language: {language}. Use: python, bash, or node" - # Security check - safety_error = _check_code_safety(language, code) - if safety_error: - return safety_error + # Security check (disabled temporarily) + # safety_error = _check_code_safety(language, code) + # if safety_error: + # return safety_error # Working directory is the agent's workspace/ subdirectory (must be absolute) work_dir = (ws / "workspace").resolve() @@ -3555,10 +3283,10 @@ async def _execute_code(ws: Path, arguments: dict) -> str: # Determine command and file extension if language == "python": ext = ".py" - cmd_prefix = ["python3"] + cmd_prefix = ["python"] elif language == "bash": - ext = ".sh" - cmd_prefix = ["bash"] + ext = ".bat" + cmd_prefix = ["powershell", "-Command"] elif language == "node": ext = ".js" cmd_prefix = ["node"] @@ -3576,7 +3304,8 @@ async def _execute_code(ws: Path, arguments: dict) -> str: safe_env["PYTHONDONTWRITEBYTECODE"] = "1" proc = await asyncio.create_subprocess_exec( - *cmd_prefix, str(script_path), + *cmd_prefix, + str(script_path), cwd=str(work_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -3590,8 +3319,23 @@ async def _execute_code(ws: Path, arguments: dict) -> str: await proc.communicate() return f"❌ Code execution timed out after {timeout}s" - stdout_str = stdout.decode("utf-8", errors="replace")[:10000] - stderr_str = stderr.decode("utf-8", errors="replace")[:5000] + if _sys.platform == "win32": + + def _try_decode_win(data): + if not data: + return "" + if len(data) >= 2 and data[:2] == b"\xff\xfe": + return data[2:].decode("utf-16-le", errors="replace") + null_count = sum(1 for i in range(1, min(len(data), 1000), 2) if data[i] == 0) + if null_count / max(len(data) // 2, 1) > 0.3: + return data.decode("utf-16-le", errors="replace") + return data.decode("gbk", errors="replace") + + stdout_str = _try_decode_win(stdout)[:10000] + stderr_str = _try_decode_win(stderr)[:5000] + else: + stdout_str = stdout.decode("utf-8", errors="replace")[:10000] + stderr_str = stderr.decode("utf-8", errors="replace")[:5000] result_parts = [] if stdout_str.strip(): @@ -3607,7 +3351,10 @@ async def _execute_code(ws: Path, arguments: dict) -> str: return "\n\n".join(result_parts) except Exception as e: - return f"❌ Execution error: {str(e)[:200]}" + import traceback + + traceback.print_exc() + return f"❌ Execution error: [{type(e).__name__}] {str(e)[:500]}" finally: # Clean up temp script try: @@ -3618,6 +3365,7 @@ async def _execute_code(ws: Path, arguments: dict) -> str: # ─── Resource Discovery Executors ─────────────────────────────── + async def _discover_resources(arguments: dict) -> str: """Search Smithery registry for MCP servers.""" query = arguments.get("query", "") @@ -3626,6 +3374,7 @@ async def _discover_resources(arguments: dict) -> str: max_results = min(arguments.get("max_results", 5), 10) from app.services.resource_discovery import search_smithery + return await search_smithery(query, max_results) @@ -3638,6 +3387,7 @@ async def _import_mcp_server(agent_id: uuid.UUID, arguments: dict) -> str: if mcp_url: # Direct URL import — bypass Smithery from app.services.resource_discovery import import_mcp_direct + server_name = arguments.get("server_id") or config.pop("server_name", None) api_key = config.pop("api_key", None) return await import_mcp_direct(mcp_url, agent_id, server_name, api_key) @@ -3648,6 +3398,7 @@ async def _import_mcp_server(agent_id: uuid.UUID, arguments: dict) -> str: return "❌ Please provide a server_id (e.g. 'github'). Use discover_resources first to find available servers." from app.services.resource_discovery import import_mcp_from_smithery + return await import_mcp_from_smithery(server_id, agent_id, config or None, reauthorize=reauthorize) @@ -3678,18 +3429,19 @@ async def _handle_set_trigger(agent_id: uuid.UUID, arguments: dict) -> str: if ttype == "cron": expr = config.get("expr", "") if not expr: - return "❌ cron trigger requires config.expr, e.g. {\"expr\": \"0 9 * * *\"}" + return '❌ cron trigger requires config.expr, e.g. {"expr": "0 9 * * *"}' try: from croniter import croniter + croniter(expr) except Exception: return f"❌ Invalid cron expression: '{expr}'" elif ttype == "once": if not config.get("at"): - return "❌ once trigger requires config.at, e.g. {\"at\": \"2026-03-10T09:00:00+08:00\"}" + return '❌ once trigger requires config.at, e.g. {"at": "2026-03-10T09:00:00+08:00"}' elif ttype == "interval": if not config.get("minutes"): - return "❌ interval trigger requires config.minutes, e.g. {\"minutes\": 30}" + return '❌ interval trigger requires config.minutes, e.g. {"minutes": 30}' elif ttype == "poll": if not config.get("url"): return "❌ poll trigger requires config.url" @@ -3702,13 +3454,18 @@ async def _handle_set_trigger(agent_id: uuid.UUID, arguments: dict) -> str: from app.models.audit import ChatMessage from app.models.chat_session import ChatSession from sqlalchemy import cast as sa_cast, String as SaString + async with async_session() as _snap_db: - _snap_q = select(ChatMessage.created_at).join( - ChatSession, ChatMessage.conversation_id == sa_cast(ChatSession.id, SaString) - ).where( - ChatSession.agent_id == agent_id, - ChatMessage.created_at.isnot(None), - ).order_by(ChatMessage.created_at.desc()).limit(1) + _snap_q = ( + select(ChatMessage.created_at) + .join(ChatSession, ChatMessage.conversation_id == sa_cast(ChatSession.id, SaString)) + .where( + ChatSession.agent_id == agent_id, + ChatMessage.created_at.isnot(None), + ) + .order_by(ChatMessage.created_at.desc()) + .limit(1) + ) _snap_r = await _snap_db.execute(_snap_q) _latest_ts = _snap_r.scalar_one_or_none() if _latest_ts: @@ -3718,6 +3475,7 @@ async def _handle_set_trigger(agent_id: uuid.UUID, arguments: dict) -> str: elif ttype == "webhook": # Auto-generate a unique token for the webhook URL import secrets + token = secrets.token_urlsafe(8) # ~11 chars, URL-safe config["token"] = token @@ -3725,14 +3483,18 @@ async def _handle_set_trigger(agent_id: uuid.UUID, arguments: dict) -> str: async with async_session() as db: # Load agent to get per-agent trigger limit from app.models.agent import Agent as _AgentModel + _a_result = await db.execute(select(_AgentModel).where(_AgentModel.id == agent_id)) _agent_obj = _a_result.scalar_one_or_none() agent_max_triggers = (_agent_obj.max_triggers if _agent_obj else None) or MAX_TRIGGERS_PER_AGENT # Check max triggers from sqlalchemy import func as sa_func + result = await db.execute( - select(sa_func.count()).select_from(AgentTrigger).where( + select(sa_func.count()) + .select_from(AgentTrigger) + .where( AgentTrigger.agent_id == agent_id, AgentTrigger.is_enabled == True, ) @@ -3777,19 +3539,27 @@ async def _handle_set_trigger(agent_id: uuid.UUID, arguments: dict) -> str: # Activity log try: from app.services.audit_logger import write_audit_log - await write_audit_log("trigger_created", { - "name": name, "type": ttype, "reason": reason[:100], - }, agent_id=agent_id) + + await write_audit_log( + "trigger_created", + { + "name": name, + "type": ttype, + "reason": reason[:100], + }, + agent_id=agent_id, + ) except Exception: pass # Return webhook URL for webhook triggers if ttype == "webhook": from app.config import get_settings + settings = get_settings() - base = getattr(settings, 'PUBLIC_URL', '') or '' + base = getattr(settings, "PUBLIC_URL", "") or "" if not base: - base = 'https://try.clawith.ai' # fallback + base = "https://try.clawith.ai" # fallback webhook_url = f"{base.rstrip('/')}/api/webhooks/t/{config['token']}" return f"✅ Webhook trigger '{name}' created.\n\nWebhook URL: {webhook_url}\n\nTell the user to configure this URL in their external service (e.g. GitHub, Grafana). When the service sends a POST to this URL, you will be woken up with the payload as context." @@ -3838,9 +3608,15 @@ async def _handle_update_trigger(agent_id: uuid.UUID, arguments: dict) -> str: try: from app.services.audit_logger import write_audit_log - await write_audit_log("trigger_updated", { - "name": name, "changes": "; ".join(changes), - }, agent_id=agent_id) + + await write_audit_log( + "trigger_updated", + { + "name": name, + "changes": "; ".join(changes), + }, + agent_id=agent_id, + ) except Exception: pass @@ -3877,6 +3653,7 @@ async def _handle_cancel_trigger(agent_id: uuid.UUID, arguments: dict) -> str: try: from app.services.audit_logger import write_audit_log + await write_audit_log("trigger_cancelled", {"name": name}, agent_id=agent_id) except Exception: pass @@ -3894,16 +3671,21 @@ async def _handle_list_triggers(agent_id: uuid.UUID) -> str: try: async with async_session() as db: result = await db.execute( - select(AgentTrigger).where( + select(AgentTrigger) + .where( AgentTrigger.agent_id == agent_id, - ).order_by(AgentTrigger.created_at.desc()) + ) + .order_by(AgentTrigger.created_at.desc()) ) triggers = result.scalars().all() if not triggers: return "No triggers found. Use set_trigger to create one." - lines = ["| Name | Type | Config | Reason | Status | Fires |", "|------|------|--------|--------|--------|-------|"] + lines = [ + "| Name | Type | Config | Reason | Status | Fires |", + "|------|------|--------|--------|--------|-------|", + ] for t in triggers: status = "✅ active" if t.is_enabled else "⏸ disabled" config_str = str(t.config)[:50] @@ -3918,6 +3700,7 @@ async def _handle_list_triggers(agent_id: uuid.UUID) -> str: # ─── Image Upload (ImageKit CDN) ──────────────────────────────── + async def _upload_image(agent_id: uuid.UUID, ws: Path, arguments: dict) -> str: """Upload an image to ImageKit CDN and return the public URL. @@ -3941,6 +3724,7 @@ async def _upload_image(agent_id: uuid.UUID, ws: Path, arguments: dict) -> str: url_endpoint = "" try: from app.models.tool import Tool, AgentTool + async with async_session() as db: # Global config r = await db.execute(select(Tool).where(Tool.name == "upload_image")) @@ -3994,6 +3778,7 @@ async def _upload_image(agent_id: uuid.UUID, ws: Path, arguments: dict) -> str: form_data["file"] = url if not file_name: from urllib.parse import urlparse + file_name = urlparse(url).path.split("/")[-1] or "image.jpg" if not file_name: @@ -4048,9 +3833,9 @@ async def _upload_image(agent_id: uuid.UUID, ws: Path, arguments: dict) -> str: return f"❌ Upload error: {type(e).__name__}: {str(e)[:300]}" - # ─── Feishu Helper ──────────────────────────────────────────────────────────── + async def _get_feishu_token(agent_id: uuid.UUID) -> tuple[str, str] | None: """Get (app_id, app_access_token) for the agent's configured Feishu channel.""" import httpx @@ -4085,6 +3870,7 @@ async def _get_agent_calendar_id(token: str) -> tuple[str | None, str | None]: Returns (calendar_id, None) on success, or (None, human_readable_error) on failure. """ import httpx + async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( "https://open.feishu.cn/open-apis/calendar/v4/calendars/primary", @@ -4115,6 +3901,7 @@ async def _get_agent_calendar_id(token: str) -> tuple[str | None, str | None]: async def _feishu_resolve_open_id(token: str, email: str) -> str | None: """Resolve a user's open_id from their email.""" import httpx + async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( "https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id", @@ -4135,6 +3922,7 @@ async def _feishu_resolve_open_id(token: str, email: str) -> str | None: def _iso_to_ts(iso_str: str) -> float: """Convert ISO 8601 string to Unix timestamp.""" from datetime import datetime as _dt + for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"): try: if iso_str.endswith("Z"): @@ -4151,10 +3939,12 @@ def _iso_to_ts(iso_str: str) -> float: # ─── Feishu Wiki Tools ─────────────────────────────────────────────────────── + async def _feishu_wiki_get_node(token_str: str, auth_token: str) -> dict | None: """Call wiki get_node API to resolve a wiki node token → {obj_token, space_id, has_child, title}. Returns None if the token is not a wiki node.""" import httpx + async with httpx.AsyncClient(timeout=5) as client: r = await client.get( "https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node", @@ -4244,14 +4034,15 @@ async def _list_children(parent_token: str, depth: int) -> list[dict]: f"{indent} obj_token: `{p['obj_token']}`" ) lines.append( - "\n💡 用 `feishu_doc_read(document_token=\"\")` 读取每个子页面的内容。" - "\n 对有子页面的条目,再次调用 `feishu_wiki_list(node_token=\"...\")` 继续展开。" + '\n💡 用 `feishu_doc_read(document_token="")` 读取每个子页面的内容。' + '\n 对有子页面的条目,再次调用 `feishu_wiki_list(node_token="...")` 继续展开。' ) return "\n".join(lines) async def _feishu_doc_read(agent_id: uuid.UUID, arguments: dict) -> str: import httpx + document_token = arguments.get("document_token", "").strip() if not document_token: return "❌ Missing required argument 'document_token'" @@ -4299,6 +4090,7 @@ async def _feishu_doc_read(agent_id: uuid.UUID, arguments: dict) -> str: async def _feishu_doc_create(agent_id: uuid.UUID, arguments: dict) -> str: import httpx + title = arguments.get("title", "").strip() if not title: return "❌ Missing required argument 'title'" @@ -4356,7 +4148,7 @@ async def _feishu_doc_create(agent_id: uuid.UUID, arguments: dict) -> str: f"标题:{title}\n" f"Token:{doc_token}\n" f"🔗 访问链接:{doc_url}\n" - f"下一步:调用 feishu_doc_append(document_token=\"{doc_token}\", content=\"...\") 写入正文内容。" + f'下一步:调用 feishu_doc_append(document_token="{doc_token}", content="...") 写入正文内容。' ) @@ -4377,11 +4169,11 @@ def _make_run(content: str, style: dict | None = None) -> dict: elements = [] # Only handle **bold**, *italic*, ~~strikethrough~~; backticks become plain text - pattern = r'(\*\*(.+?)\*\*|\*(.+?)\*|~~(.+?)~~|`(.+?)`)' + pattern = r"(\*\*(.+?)\*\*|\*(.+?)\*|~~(.+?)~~|`(.+?)`)" pos = 0 for m in _re.finditer(pattern, text): if m.start() > pos: - elements.append(_make_run(text[pos:m.start()])) + elements.append(_make_run(text[pos : m.start()])) raw = m.group(0) if raw.startswith("**"): elements.append(_make_run(m.group(2), {"bold": True})) @@ -4415,8 +4207,7 @@ def _markdown_to_feishu_blocks(markdown: str) -> list[dict]: """ import re as _re - _HEADING_BLOCK = {1: (3, "heading1"), 2: (4, "heading2"), - 3: (5, "heading3"), 4: (6, "heading4")} + _HEADING_BLOCK = {1: (3, "heading1"), 2: (4, "heading2"), 3: (5, "heading3"), 4: (6, "heading4")} def _text_block(bt: int, key: str, line: str) -> dict: # Omit "style" entirely to avoid Feishu field validation errors on empty style dicts @@ -4439,30 +4230,47 @@ def _text_block(bt: int, key: str, line: str) -> dict: while i < len(lines) and not lines[i].strip().startswith("```"): code_lines.append(lines[i]) i += 1 - blocks.append({ - "block_type": 14, - "code": { - "elements": [{"text_run": {"content": "\n".join(code_lines)}}], - "style": {"language": 1 if not lang else - {"python": 49, "javascript": 22, "js": 22, - "typescript": 56, "ts": 56, "bash": 4, "sh": 4, - "sql": 53, "java": 21, "go": 17, "rust": 51, - "json": 25, "yaml": 60, "html": 19, "css": 10, - }.get(lang.lower(), 1)}, - }, - }) + blocks.append( + { + "block_type": 14, + "code": { + "elements": [{"text_run": {"content": "\n".join(code_lines)}}], + "style": { + "language": 1 + if not lang + else { + "python": 49, + "javascript": 22, + "js": 22, + "typescript": 56, + "ts": 56, + "bash": 4, + "sh": 4, + "sql": 53, + "java": 21, + "go": 17, + "rust": 51, + "json": 25, + "yaml": 60, + "html": 19, + "css": 10, + }.get(lang.lower(), 1) + }, + }, + } + ) i += 1 continue # ── Divider ────────────────────────────────────────────────────────── - if _re.fullmatch(r'[-*_]{3,}', line.strip()): + if _re.fullmatch(r"[-*_]{3,}", line.strip()): # block_type 22 = Divider; no extra fields allowed (empty dict causes validation error) blocks.append({"block_type": 22}) i += 1 continue # ── Headings ───────────────────────────────────────────────────────── - hm = _re.match(r'^(#{1,4})\s+(.*)', line) + hm = _re.match(r"^(#{1,4})\s+(.*)", line) if hm: level = min(len(hm.group(1)), 4) bt, key = _HEADING_BLOCK[level] @@ -4471,15 +4279,15 @@ def _text_block(bt: int, key: str, line: str) -> dict: continue # ── Bullet list ────────────────────────────────────────────────────── - if _re.match(r'^[\-\*\+]\s+', line): - text = _re.sub(r'^[\-\*\+]\s+', '', line) + if _re.match(r"^[\-\*\+]\s+", line): + text = _re.sub(r"^[\-\*\+]\s+", "", line) blocks.append(_text_block(12, "bullet", text)) i += 1 continue # ── Ordered list ───────────────────────────────────────────────────── - if _re.match(r'^\d+\.\s+', line): - text = _re.sub(r'^\d+\.\s+', '', line) + if _re.match(r"^\d+\.\s+", line): + text = _re.sub(r"^\d+\.\s+", "", line) blocks.append(_text_block(13, "ordered", text)) i += 1 continue @@ -4492,15 +4300,17 @@ def _text_block(bt: int, key: str, line: str) -> dict: # ── Empty line → empty text block ──────────────────────────────────── if line.strip() == "": - blocks.append({ - "block_type": 2, - "text": {"elements": [{"text_run": {"content": " "}}]}, - }) + blocks.append( + { + "block_type": 2, + "text": {"elements": [{"text_run": {"content": " "}}]}, + } + ) i += 1 continue # ── Markdown table separator line (|---|---| ) → skip ─────────────── - if _re.match(r'^\|[\s\-:]+(\|[\s\-:]+)*\|?\s*$', line.strip()): + if _re.match(r"^\|[\s\-:]+(\|[\s\-:]+)*\|?\s*$", line.strip()): i += 1 continue @@ -4522,6 +4332,7 @@ def _text_block(bt: int, key: str, line: str) -> dict: async def _feishu_doc_append(agent_id: uuid.UUID, arguments: dict) -> str: import httpx + document_token = arguments.get("document_token", "").strip() content = arguments.get("content", "").strip() if not document_token: @@ -4540,38 +4351,37 @@ async def _feishu_doc_append(agent_id: uuid.UUID, arguments: dict) -> str: docx_token = node_info["obj_token"] if (node_info and node_info.get("obj_token")) else document_token async with httpx.AsyncClient(timeout=20) as client: - meta = (await client.get( - f"https://open.feishu.cn/open-apis/docx/v1/documents/{docx_token}", - headers=headers, - )).json() + meta = ( + await client.get( + f"https://open.feishu.cn/open-apis/docx/v1/documents/{docx_token}", + headers=headers, + ) + ).json() if meta.get("code") != 0: return f"❌ Cannot access document: {meta.get('msg')}" - body_block_id = ( - meta.get("data", {}).get("document", {}).get("body", {}).get("block_id") - or docx_token - ) + body_block_id = meta.get("data", {}).get("document", {}).get("body", {}).get("block_id") or docx_token children = _markdown_to_feishu_blocks(content) - result = (await client.post( - f"https://open.feishu.cn/open-apis/docx/v1/documents/{docx_token}/blocks/{body_block_id}/children", - json={"children": children, "index": -1}, - headers=headers, - )).json() + result = ( + await client.post( + f"https://open.feishu.cn/open-apis/docx/v1/documents/{docx_token}/blocks/{body_block_id}/children", + json={"children": children, "index": -1}, + headers=headers, + ) + ).json() if result.get("code") != 0: return f"❌ Failed to append: {result.get('msg')} (code {result.get('code')})" doc_url = f"https://bytedance.larkoffice.com/docx/{docx_token}" - return ( - f"✅ 已写入 {len(children)} 个段落到文档。\n" - f"🔗 文档直链(原文发给用户,勿修改):{doc_url}" - ) + return f"✅ 已写入 {len(children)} 个段落到文档。\n🔗 文档直链(原文发给用户,勿修改):{doc_url}" # ─── Feishu Document Share ──────────────────────────────────────────────────── + async def _feishu_doc_share(agent_id: uuid.UUID, arguments: dict) -> str: """Manage Feishu document collaborators. Automatically handles both regular docx documents (Drive permissions API) @@ -4623,11 +4433,7 @@ async def _feishu_doc_share(agent_id: uuid.UUID, arguments: dict) -> str: "请直接在飞书知识库中管理成员权限。" ) if _c in (99991672, 99991668): - return ( - f"❌ 权限不足(code {_c})\n" - "需要在飞书开放平台开通:\n" - "• drive:drive(云文档权限管理)" - ) + return f"❌ 权限不足(code {_c})\n需要在飞书开放平台开通:\n• drive:drive(云文档权限管理)" return f"❌ 获取协作者列表失败:{data.get('msg')} (code {_c})" members = data.get("data", {}).get("items", []) @@ -4639,7 +4445,9 @@ async def _feishu_doc_share(agent_id: uuid.UUID, arguments: dict) -> str: perm = m.get("perm", "") member_type = m.get("member_type", "") member_id = m.get("member_id", "") - _type_label = {"openid": "用户", "openchat": "群组", "opendepartmentid": "部门"}.get(member_type, member_type) + _type_label = {"openid": "用户", "openchat": "群组", "opendepartmentid": "部门"}.get( + member_type, member_type + ) lines.append(f"• {_type_label} `{member_id}` | 权限: **{perm}**") return "\n".join(lines) @@ -4654,7 +4462,7 @@ async def _feishu_doc_share(agent_id: uuid.UUID, arguments: dict) -> str: resolved: list[tuple[str, str]] = [] # (display_name, open_id) for name in member_names: sr = await _feishu_user_search(agent_id, {"name": name}) - m = _re.search(r'open_id: `(ou_[A-Za-z0-9]+)`', sr) + m = _re.search(r"open_id: `(ou_[A-Za-z0-9]+)`", sr) if m: resolved.append((name, m.group(1))) else: @@ -4687,10 +4495,7 @@ async def _feishu_doc_share(agent_id: uuid.UUID, arguments: dict) -> str: results.append(f"ℹ️ 「{display}」已经是知识库成员,无需重复添加") elif _c == 131101: # Public wiki space — everyone already has access - results.append( - f"ℹ️ 这是一个**公开知识库**,所有人已可访问。\n" - f"「{display}」无需单独添加权限。" - ) + results.append(f"ℹ️ 这是一个**公开知识库**,所有人已可访问。\n「{display}」无需单独添加权限。") else: results.append(f"❌ 添加「{display}」到知识库失败:{d.get('msg')} (code {_c})") continue @@ -4720,11 +4525,7 @@ async def _feishu_doc_share(agent_id: uuid.UUID, arguments: dict) -> str: f"请手动操作:打开文档 → 右上角「分享」→ 添加自己并设置权限。" ) elif _c in (99991672, 99991668): - return ( - f"❌ 权限不足(code {_c})\n" - "需要在飞书开放平台开通:\n" - "• drive:drive(云文档权限管理)" - ) + return f"❌ 权限不足(code {_c})\n需要在飞书开放平台开通:\n• drive:drive(云文档权限管理)" else: results.append(f"❌ 添加「{display}」失败:{d.get('msg')} (code {_c})") @@ -4758,6 +4559,7 @@ async def _feishu_doc_share(agent_id: uuid.UUID, arguments: dict) -> str: # ─── Feishu Calendar Tools ──────────────────────────────────────────────────── + async def _feishu_calendar_list(agent_id: uuid.UUID, arguments: dict) -> str: import httpx import re as _re @@ -4776,8 +4578,9 @@ def _to_iso(t: str | None, default: datetime) -> str: """Return an ISO-8601 string with timezone for freebusy API.""" if not t: return default.strftime("%Y-%m-%dT%H:%M:%S+00:00") - if _re.fullmatch(r'\d+', t.strip()): + if _re.fullmatch(r"\d+", t.strip()): from datetime import datetime as _dt2 + return _dt2.fromtimestamp(int(t.strip()), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") return t.strip() @@ -4785,10 +4588,11 @@ def _to_unix(t: str | None, default: datetime) -> str: """Convert ISO-8601 / Unix string / None to Unix timestamp string.""" if not t: return str(int(default.timestamp())) - if _re.fullmatch(r'\d+', t.strip()): + if _re.fullmatch(r"\d+", t.strip()): return t.strip() try: from datetime import datetime as _dt2 + for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"): try: dt = _dt2.strptime(t.strip(), fmt) @@ -4798,6 +4602,7 @@ def _to_unix(t: str | None, default: datetime) -> str: except ValueError: continue from dateutil import parser as _dp + return str(int(_dp.parse(t).timestamp())) except Exception: return str(int(default.timestamp())) @@ -4839,6 +4644,7 @@ def _to_unix(t: str | None, default: datetime) -> str: if busy_slots: from datetime import datetime as _dt2 from zoneinfo import ZoneInfo + tz_cn = ZoneInfo("Asia/Shanghai") busy_lines = [] for slot in sorted(busy_slots, key=lambda x: x.get("start_time", "")): @@ -4897,6 +4703,7 @@ def _to_unix(t: str | None, default: datetime) -> str: event_id = ev.get("event_id", "") try: from datetime import datetime as _dt + s = _dt.fromtimestamp(int(start), tz=timezone.utc).strftime("%m-%d %H:%M") if start else "?" e = _dt.fromtimestamp(int(end_t), tz=timezone.utc).strftime("%H:%M") if end_t else "?" except Exception: @@ -4932,7 +4739,9 @@ async def _feishu_calendar_create(agent_id: uuid.UUID, arguments: dict) -> str: if user_email: organizer_open_id = await _feishu_resolve_open_id(token, user_email) if not organizer_open_id: - logger.warning(f"[Feishu Calendar] Could not resolve open_id for '{user_email}', continuing without organizer invite") + logger.warning( + f"[Feishu Calendar] Could not resolve open_id for '{user_email}', continuing without organizer invite" + ) agent_cal_id, cal_err = await _get_agent_calendar_id(token) if not agent_cal_id: @@ -4967,26 +4776,27 @@ async def _feishu_calendar_create(agent_id: uuid.UUID, arguments: dict) -> str: attendee_display: list[str] = [] # for summary message # 1. Direct open_ids provided by caller - for oid in (arguments.get("attendee_open_ids") or []): + for oid in arguments.get("attendee_open_ids") or []: if oid and oid not in attendee_open_ids: attendee_open_ids.append(oid) attendee_display.append(oid) # 2. Names → look up via feishu_user_search import re as _re_oid - for aname in (arguments.get("attendee_names") or []): + + for aname in arguments.get("attendee_names") or []: aname = aname.strip() if not aname: continue _sr = await _feishu_user_search(agent_id, {"name": aname}) - _m = _re_oid.search(r'open_id: `(ou_[A-Za-z0-9]+)`', _sr) + _m = _re_oid.search(r"open_id: `(ou_[A-Za-z0-9]+)`", _sr) if _m: _oid = _m.group(1) if _oid not in attendee_open_ids: attendee_open_ids.append(_oid) attendee_display.append(aname) else: - logger.warning(f"[Calendar] Could not resolve attendee '{aname}': {_sr[:100]}") + logger.warning(f"[Calendar] Could not resolve attendee '{aname}': {_sr[:100]}") # 3. From explicit attendee_emails attendee_emails: list[str] = list(arguments.get("attendee_emails") or []) @@ -5110,6 +4920,7 @@ async def _feishu_calendar_delete(agent_id: uuid.UUID, arguments: dict) -> str: # ─── Feishu User Search ─────────────────────────────────────────────────────── + async def _feishu_user_search(agent_id: uuid.UUID, arguments: dict) -> str: """Search for colleagues in the Feishu directory by name. @@ -5144,10 +4955,7 @@ async def _feishu_user_search(agent_id: uuid.UUID, arguments: dict) -> str: name_lower = name.lower() def _matches(u: dict) -> bool: - return ( - name_lower in (u.get("name") or "").lower() - or name_lower in (u.get("en_name") or "").lower() - ) + return name_lower in (u.get("name") or "").lower() or name_lower in (u.get("en_name") or "").lower() matched = [u for u in _cached_users if _matches(u)] @@ -5173,10 +4981,9 @@ def _matches(u: dict) -> bool: from app.database import async_session as _async_session from sqlalchemy import select as _sa_select from app.models.org import OrgMember as _OrgMember + async with _async_session() as _db: - _r = await _db.execute( - _sa_select(_OrgMember).where(_OrgMember.name.ilike(f"%{name}%")) - ) + _r = await _db.execute(_sa_select(_OrgMember).where(_OrgMember.name.ilike(f"%{name}%"))) _org_members = _r.scalars().all() if _org_members: lines = [f"🔍 从通讯录找到 {len(_org_members)} 位匹配「{name}」的用户:\n"] @@ -5199,10 +5006,9 @@ def _matches(u: dict) -> bool: from app.database import async_session as _async_session from sqlalchemy import select as _sa_select from app.models.user import User as _User + async with _async_session() as _db: - _r = await _db.execute( - _sa_select(_User).where(_User.display_name.ilike(f"%{name}%")) - ) + _r = await _db.execute(_sa_select(_User).where(_User.display_name.ilike(f"%{name}%"))) _platform_users = _r.scalars().all() for _pu in _platform_users: _uid = getattr(_pu, "feishu_user_id", None) @@ -5240,6 +5046,7 @@ def _matches(u: dict) -> bool: async def _feishu_contacts_refresh(agent_id: uuid.UUID) -> None: """Force-clear the local contacts cache so next search re-fetches from API.""" import pathlib as _pl + _cache_file = _pl.Path("/data/workspaces") / str(agent_id) / "feishu_contacts_cache.json" try: if _cache_file.exists(): @@ -5250,6 +5057,7 @@ async def _feishu_contacts_refresh(agent_id: uuid.UUID) -> None: # ─── Email Tool Helpers ───────────────────────────────────── + async def _get_email_config(agent_id: uuid.UUID) -> dict: """Retrieve per-agent email config from the send_email tool's AgentTool config.""" from app.models.tool import Tool, AgentTool @@ -5274,116 +5082,6 @@ async def _get_email_config(agent_id: uuid.UUID) -> dict: return {**(tool.config or {}), **agent_config} -# ── Pages: public HTML hosting ────────────────────────── - -async def _publish_page(agent_id: uuid.UUID, user_id: uuid.UUID, ws: Path, arguments: dict) -> str: - """Publish an HTML file as a public page.""" - import secrets - import re - - path = arguments.get("path", "") - if not path: - return "Missing required argument 'path'" - - # Validate file extension - if not path.lower().endswith((".html", ".htm")): - return "Only .html and .htm files can be published" - - # Resolve and check file exists - full_path = (ws / path).resolve() - if not str(full_path).startswith(str(ws.resolve())): - return "Path traversal not allowed" - if not full_path.exists() or not full_path.is_file(): - return f"File not found: {path}" - - # Extract title from HTML - try: - content = full_path.read_text(encoding="utf-8", errors="replace") - title_match = re.search(r"]*>(.*?)", content, re.IGNORECASE | re.DOTALL) - title = title_match.group(1).strip()[:200] if title_match else full_path.stem - except Exception: - title = full_path.stem - - # Generate short_id - short_id = secrets.token_urlsafe(6)[:8] # 8-char URL-safe string - - # Look up tenant_id - tenant_id = None - try: - from app.models.agent import Agent as _AgModel - async with async_session() as _db: - _r = await _db.execute(select(_AgModel.tenant_id).where(_AgModel.id == agent_id)) - tenant_id = _r.scalar_one_or_none() - except Exception: - pass - - # Create record - from app.models.published_page import PublishedPage - try: - async with async_session() as db: - page = PublishedPage( - short_id=short_id, - agent_id=agent_id, - user_id=user_id, - tenant_id=tenant_id, - source_path=path, - title=title, - ) - db.add(page) - await db.commit() - except Exception as e: - return f"Failed to publish: {e}" - - # Build public URL using configured PUBLIC_BASE_URL - public_base = "" - try: - from app.models.system_settings import SystemSetting - async with async_session() as db2: - r = await db2.execute( - select(SystemSetting).where(SystemSetting.key == "platform") - ) - setting = r.scalar_one_or_none() - if setting and setting.value and setting.value.get("public_base_url"): - raw = setting.value["public_base_url"].strip().rstrip("/") - if raw and not raw.startswith("http"): - raw = f"https://{raw}" - public_base = raw - except Exception: - pass - - url = f"{public_base}/p/{short_id}" if public_base else f"/p/{short_id}" - - return f"Published successfully!\n\nPublic URL: {url}\nTitle: {title}\n\nAnyone can access this page without logging in." - - -async def _list_published_pages(agent_id: uuid.UUID) -> str: - """List all published pages for this agent.""" - from app.models.published_page import PublishedPage - - try: - async with async_session() as db: - result = await db.execute( - select(PublishedPage) - .where(PublishedPage.agent_id == agent_id) - .order_by(PublishedPage.created_at.desc()) - ) - pages = result.scalars().all() - - if not pages: - return "No published pages yet." - - lines = [f"Published pages ({len(pages)} total):\n"] - for p in pages: - lines.append(f"- {p.title or 'Untitled'}") - lines.append(f" URL: /p/{p.short_id}") - lines.append(f" Source: {p.source_path}") - lines.append(f" Views: {p.view_count}") - lines.append("") - return "\n".join(lines) - except Exception as e: - return f"Failed to list pages: {e}" - - async def _handle_email_tool(tool_name: str, agent_id: uuid.UUID, ws: Path, arguments: dict) -> str: """Dispatch email tool calls to the email_service module.""" from app.services.email_service import send_email, read_emails, reply_email diff --git a/backend/app/services/llm_client.py b/backend/app/services/llm_client.py index 411ccf8..953eae9 100644 --- a/backend/app/services/llm_client.py +++ b/backend/app/services/llm_client.py @@ -21,6 +21,7 @@ # Data Models # ============================================================================ + @dataclass class LLMMessage: """Unified message format.""" @@ -49,35 +50,31 @@ def to_anthropic_format(self) -> dict | None: """Convert to Anthropic format (returns None for system messages).""" if self.role == "system": return None - + role = self.role - + # Tool response (from user to assistant) if role == "tool": return { "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": self.tool_call_id, - "content": self.content or "" - } - ] + "content": [{"type": "tool_result", "tool_use_id": self.tool_call_id, "content": self.content or ""}], } - + content_blocks = [] - + # Add reasoning/thinking content if present if self.role == "assistant" and self.reasoning_content: - content_blocks.append({ - "type": "thinking", - "thinking": self.reasoning_content, - "signature": self.reasoning_signature or "synthetic_signature" - }) + content_blocks.append( + { + "type": "thinking", + "thinking": self.reasoning_content, + "signature": self.reasoning_signature or "synthetic_signature", + } + ) if self.content: content_blocks.append({"type": "text", "text": self.content}) - + # Tool requests (from assistant to user) if self.tool_calls: for tc in self.tool_calls: @@ -88,14 +85,11 @@ def to_anthropic_format(self) -> dict | None: args = json.loads(args) except json.JSONDecodeError: args = {} - - content_blocks.append({ - "type": "tool_use", - "id": tc.get("id", ""), - "name": function_call.get("name", ""), - "input": args - }) - + + content_blocks.append( + {"type": "tool_use", "id": tc.get("id", ""), "name": function_call.get("name", ""), "input": args} + ) + # Handle the structure if len(content_blocks) == 1 and content_blocks[0]["type"] == "text": content = content_blocks[0]["text"] @@ -143,6 +137,7 @@ class LLMStreamChunk: # Base Client Interface # ============================================================================ + class LLMClient(ABC): """Abstract base class for LLM clients.""" @@ -194,6 +189,7 @@ def _get_headers(self) -> dict[str, str]: # OpenAI-Compatible Client # ============================================================================ + class OpenAICompatibleClient(LLMClient): """Client for OpenAI-compatible APIs (OpenAI, DeepSeek, Qwen, etc.).""" @@ -214,7 +210,7 @@ def __init__( async def _get_client(self) -> httpx.AsyncClient: """Get or create HTTP client.""" if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) + self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) return self._client def _get_headers(self) -> dict[str, str]: @@ -270,13 +266,10 @@ def _parse_stream_line( line: str, in_think: bool, tag_buffer: str, - json_buffer: str = "", - ) -> tuple[LLMStreamChunk, bool, str, str]: + ) -> tuple[LLMStreamChunk, bool, str]: """Parse a single SSE line from stream. - Returns (chunk, new_in_think, new_tag_buffer, new_json_buffer). - The json_buffer accumulates partial JSON from non-standard APIs that - split a single JSON object across multiple data: lines. + Returns (chunk, new_in_think, new_tag_buffer). """ chunk = LLMStreamChunk() @@ -286,32 +279,17 @@ def _parse_stream_line( elif line.startswith("data:"): data_str = line[5:] else: - # Non-data lines (comments, event types, empty) — never buffer - return chunk, in_think, tag_buffer, json_buffer + return chunk, in_think, tag_buffer data_str = data_str.strip() - if not data_str: - return chunk, in_think, tag_buffer, json_buffer - if data_str == "[DONE]": chunk.is_finished = True - return chunk, in_think, tag_buffer, "" - - # Accumulate into json_buffer for split JSON handling - if json_buffer: - json_buffer += data_str - else: - json_buffer = data_str + return chunk, in_think, tag_buffer try: - data = json.loads(json_buffer) - json_buffer = "" # Reset on successful parse + data = json.loads(data_str) except json.JSONDecodeError: - # Cap buffer at 64KB to prevent memory leaks - if len(json_buffer) > 65536: - logger.warning("[LLM] JSON buffer exceeded 64KB, discarding") - json_buffer = "" - return chunk, in_think, tag_buffer, json_buffer + return chunk, in_think, tag_buffer if "error" in data: raise LLMError(f"Stream error: {data['error']}") @@ -322,7 +300,7 @@ def _parse_stream_line( choices = data.get("choices", []) if not choices: - return chunk, in_think, tag_buffer, json_buffer + return chunk, in_think, tag_buffer choice = choices[0] delta = choice.get("delta", {}) @@ -337,9 +315,7 @@ def _parse_stream_line( # Regular content with think tag filtering if delta.get("content"): text = delta["content"] - chunk.content, in_think, tag_buffer = self._filter_think_tags( - text, in_think, tag_buffer - ) + chunk.content, in_think, tag_buffer = self._filter_think_tags(text, in_think, tag_buffer) # Tool calls if delta.get("tool_calls"): @@ -347,11 +323,9 @@ def _parse_stream_line( chunk.tool_call = tc_delta break # Return one at a time - return chunk, in_think, tag_buffer, json_buffer + return chunk, in_think, tag_buffer - def _filter_think_tags( - self, text: str, in_think: bool, tag_buffer: str - ) -> tuple[str, bool, str]: + def _filter_think_tags(self, text: str, in_think: bool, tag_buffer: str) -> tuple[str, bool, str]: """Filter out ... tags from content. Returns (filtered_content, new_in_think, new_tag_buffer). @@ -451,7 +425,6 @@ async def stream( in_think = False tag_buffer = "" - json_buffer = "" # Buffer for non-standard APIs with split JSON (inspired by PR #120) max_retries = 3 client = await self._get_client() @@ -466,9 +439,7 @@ async def stream( raise LLMError(f"HTTP {resp.status_code}: {error_body[:500]}") async for line in resp.aiter_lines(): - chunk, in_think, tag_buffer, json_buffer = self._parse_stream_line( - line, in_think, tag_buffer, json_buffer - ) + chunk, in_think, tag_buffer = self._parse_stream_line(line, in_think, tag_buffer) if chunk.is_finished: break @@ -518,7 +489,6 @@ async def stream( tool_calls_data = [] in_think = False tag_buffer = "" - json_buffer = "" else: raise LLMError(f"Connection failed after {max_retries} attempts: {e}") @@ -544,6 +514,7 @@ async def close(self) -> None: # OpenAI Responses API Client # ============================================================================ + class OpenAIResponsesClient(LLMClient): """Client for OpenAI Responses API (`/v1/responses`).""" @@ -564,7 +535,7 @@ def __init__( async def _get_client(self) -> httpx.AsyncClient: """Get or create HTTP client.""" if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) + self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) return self._client def _get_headers(self) -> dict[str, str]: @@ -616,19 +587,23 @@ def _messages_to_input(self, messages: list[LLMMessage]) -> list[dict[str, Any]] args = fn.get("arguments", "{}") if isinstance(args, dict): args = json.dumps(args, ensure_ascii=False) - input_items.append({ - "type": "function_call", - "call_id": tc.get("id", ""), - "name": fn.get("name", ""), - "arguments": str(args or "{}"), - }) + input_items.append( + { + "type": "function_call", + "call_id": tc.get("id", ""), + "name": fn.get("name", ""), + "arguments": str(args or "{}"), + } + ) if msg.role == "tool": - input_items.append({ - "type": "function_call_output", - "call_id": msg.tool_call_id or "", - "output": msg.content or "", - }) + input_items.append( + { + "type": "function_call_output", + "call_id": msg.tool_call_id or "", + "output": msg.content or "", + } + ) return input_items @@ -642,12 +617,14 @@ def _convert_tools(self, tools: list[dict] | None) -> list[dict] | None: if tool.get("type") != "function": continue fn = tool.get("function", {}) - converted.append({ - "type": "function", - "name": fn.get("name", ""), - "description": fn.get("description", ""), - "parameters": fn.get("parameters", {"type": "object"}), - }) + converted.append( + { + "type": "function", + "name": fn.get("name", ""), + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {"type": "object"}), + } + ) return converted or None def _build_payload( @@ -698,14 +675,16 @@ def _parse_response_data(self, data: dict[str, Any]) -> LLMResponse: args = item.get("arguments", "{}") if isinstance(args, dict): args = json.dumps(args, ensure_ascii=False) - tool_calls.append({ - "id": item.get("call_id") or item.get("id", ""), - "type": "function", - "function": { - "name": item.get("name", ""), - "arguments": str(args or "{}"), - }, - }) + tool_calls.append( + { + "id": item.get("call_id") or item.get("id", ""), + "type": "function", + "function": { + "name": item.get("name", ""), + "arguments": str(args or "{}"), + }, + } + ) # Some Responses payloads include a pre-aggregated output_text field. # Use it as a fallback when output blocks are empty. @@ -840,6 +819,7 @@ async def close(self) -> None: # Gemini Native Client # ============================================================================ + class GeminiClient(LLMClient): """Client for Gemini native API (`generateContent` / `streamGenerateContent`).""" @@ -861,7 +841,7 @@ def __init__( async def _get_client(self) -> httpx.AsyncClient: """Get or create HTTP client.""" if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) + self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) return self._client async def _get_openai_fallback_client(self) -> OpenAICompatibleClient: @@ -898,7 +878,7 @@ def _normalize_model_name(self) -> str: """Normalize model id for native Gemini endpoint path.""" model = (self.model or "").strip() if model.startswith("models/"): - model = model[len("models/"):] + model = model[len("models/") :] return model def _parse_data_url_image(self, data_url: str) -> tuple[str, str] | None: @@ -932,12 +912,14 @@ def _content_to_gemini_parts(self, content: Any) -> list[dict[str, Any]]: parsed = self._parse_data_url_image(image_url) if parsed: mime_type, b64_data = parsed - parts.append({ - "inlineData": { - "mimeType": mime_type, - "data": b64_data, + parts.append( + { + "inlineData": { + "mimeType": mime_type, + "data": b64_data, + } } - }) + ) elif image_url: # Gemini native API requires uploaded files or inline data; # preserve reference in text when URL cannot be inlined. @@ -1029,12 +1011,14 @@ def _build_payload( parsed_args = args else: parsed_args = {} - parts.append({ - "functionCall": { - "name": fn.get("name", ""), - "args": parsed_args, + parts.append( + { + "functionCall": { + "name": fn.get("name", ""), + "args": parsed_args, + } } - }) + ) if parts: contents.append({"role": "model", "parts": parts}) continue @@ -1056,15 +1040,19 @@ def _build_payload( else: response_obj = {"result": str(response_content)} - contents.append({ - "role": "user", - "parts": [{ - "functionResponse": { - "name": name, - "response": response_obj, - } - }], - }) + contents.append( + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": name, + "response": response_obj, + } + } + ], + } + ) payload: dict[str, Any] = { "contents": contents or [{"role": "user", "parts": [{"text": ""}]}], @@ -1077,9 +1065,7 @@ def _build_payload( payload["generationConfig"]["maxOutputTokens"] = max_tokens if system_blocks: - payload["systemInstruction"] = { - "parts": [{"text": "\n\n".join(system_blocks)}] - } + payload["systemInstruction"] = {"parts": [{"text": "\n\n".join(system_blocks)}]} tools_payload, tool_config = self._convert_tools(tools) if tools_payload: @@ -1142,14 +1128,16 @@ def _parse_response_data(self, data: dict[str, Any]) -> LLMResponse: if dedup_key in seen_tool_calls: continue seen_tool_calls.add(dedup_key) - tool_calls.append({ - "id": f"call_{len(tool_calls) + 1}", - "type": "function", - "function": { - "name": name, - "arguments": args_str, - }, - }) + tool_calls.append( + { + "id": f"call_{len(tool_calls) + 1}", + "type": "function", + "function": { + "name": name, + "arguments": args_str, + }, + } + ) usage = self._normalize_usage(data.get("usageMetadata")) @@ -1249,7 +1237,7 @@ async def stream( async for line in resp.aiter_lines(): if not line.startswith("data:"): continue - data_str = line[len("data:"):].strip() + data_str = line[len("data:") :].strip() if not data_str or data_str == "[DONE]": continue @@ -1287,14 +1275,16 @@ async def stream( if dedup_key in seen_tool_calls: continue seen_tool_calls.add(dedup_key) - tool_calls.append({ - "id": f"call_{len(tool_calls) + 1}", - "type": "function", - "function": { - "name": name, - "arguments": args_str, - }, - }) + tool_calls.append( + { + "id": f"call_{len(tool_calls) + 1}", + "type": "function", + "function": { + "name": name, + "arguments": args_str, + }, + } + ) except (httpx.ConnectError, httpx.ReadError, httpx.ConnectTimeout) as e: raise LLMError(f"Connection failed: {e}") @@ -1319,9 +1309,10 @@ async def close(self) -> None: # Anthropic Native Client # ============================================================================ + class AnthropicClient(LLMClient): """Client for Anthropic's native Messages API. - + Supports Claude 3.x and Claude 3.7+ with extended thinking. """ @@ -1341,7 +1332,7 @@ def __init__( async def _get_client(self) -> httpx.AsyncClient: """Get or create HTTP client.""" if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) + self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) return self._client def _get_headers(self) -> dict[str, str]: @@ -1397,11 +1388,13 @@ def _build_payload( for tool in tools: if tool.get("type") == "function": func = tool["function"] - anthropic_tools.append({ - "name": func["name"], - "description": func.get("description", ""), - "input_schema": func.get("parameters", {"type": "object"}), - }) + anthropic_tools.append( + { + "name": func["name"], + "description": func.get("description", ""), + "input_schema": func.get("parameters", {"type": "object"}), + } + ) payload["tools"] = anthropic_tools payload.update(kwargs) @@ -1434,7 +1427,7 @@ async def complete( full_reasoning = "" full_signature = None tool_calls = [] - + for block in data.get("content", []): if block.get("type") == "text": full_content += block.get("text", "") @@ -1442,14 +1435,16 @@ async def complete( full_reasoning += block.get("thinking", "") full_signature = block.get("signature") elif block.get("type") == "tool_use": - tool_calls.append({ - "id": block.get("id"), - "type": "function", - "function": { - "name": block.get("name"), - "arguments": json.dumps(block.get("input", {}), ensure_ascii=False) + tool_calls.append( + { + "id": block.get("id"), + "type": "function", + "function": { + "name": block.get("name"), + "arguments": json.dumps(block.get("input", {}), ensure_ascii=False), + }, } - }) + ) usage = None if "usage" in data: @@ -1492,7 +1487,7 @@ async def stream( final_model = self.model client = await self._get_client() - + try: async with client.stream("POST", url, json=payload, headers=self._get_headers()) as resp: if resp.status_code >= 400: @@ -1502,22 +1497,22 @@ async def stream( raise LLMError(f"HTTP {resp.status_code}: {error_body[:500]}") current_event = None - + async for line in resp.aiter_lines(): if not line.strip(): continue - + if line.startswith("event:"): - current_event = line[len("event:"):].strip() + current_event = line[len("event:") :].strip() continue - + if not line.startswith("data:"): continue - - data_str = line[len("data:"):].strip() + + data_str = line[len("data:") :].strip() if data_str == "[DONE]": break - + try: data = json.loads(data_str) except json.JSONDecodeError: @@ -1530,43 +1525,45 @@ async def stream( final_model = msg["model"] if msg.get("usage"): final_usage = msg["usage"] - + elif current_event == "content_block_start": block = data.get("content_block", {}) idx = data.get("index", 0) if block.get("type") == "tool_use": tool_call_index_map[idx] = len(tool_calls_data) - tool_calls_data.append({ - "id": block.get("id"), - "type": "function", - "function": {"name": block.get("name"), "arguments": ""} - }) - + tool_calls_data.append( + { + "id": block.get("id"), + "type": "function", + "function": {"name": block.get("name"), "arguments": ""}, + } + ) + elif current_event == "content_block_delta": idx = data.get("index", 0) delta = data.get("delta", {}) delta_type = delta.get("type") - + if delta_type == "text_delta": text = delta.get("text", "") full_content += text if on_chunk: await on_chunk(text) - + elif delta_type == "thinking_delta": thought = delta.get("thinking", "") full_reasoning += thought if on_thinking: await on_thinking(thought) - + elif delta_type == "signature_delta": full_signature = delta.get("signature") - + elif delta_type == "input_json_delta": if idx in tool_call_index_map: tc_idx = tool_call_index_map[idx] tool_calls_data[tc_idx]["function"]["arguments"] += delta.get("partial_json", "") - + elif current_event == "message_delta": delta = data.get("delta", {}) if delta.get("stop_reason"): @@ -1574,10 +1571,12 @@ async def stream( if data.get("usage"): # message_delta usage is cumulative final_usage = data["usage"] - + elif current_event == "error": error_info = data.get("error", {}) - raise LLMError(f"Anthropic stream error ({error_info.get('type')}): {error_info.get('message')}") + raise LLMError( + f"Anthropic stream error ({error_info.get('type')}): {error_info.get('message')}" + ) elif current_event == "message_stop": break @@ -1611,6 +1610,7 @@ async def close(self) -> None: # Factory and Utilities # ============================================================================ + @dataclass(frozen=True) class ProviderSpec: """Provider registry entry.""" @@ -1703,14 +1703,6 @@ class ProviderSpec: default_base_url="https://open.bigmodel.cn/api/paas/v4", default_max_tokens=8192, ), - "baidu": ProviderSpec( - provider="baidu", - display_name="Baidu (Qianfan)", - protocol="openai_compatible", - default_base_url="https://qianfan.baidubce.com/v2", - supports_tool_choice=False, - default_max_tokens=4096, - ), "gemini": ProviderSpec( provider="gemini", display_name="Gemini", @@ -1771,16 +1763,18 @@ def get_provider_manifest() -> list[dict[str, Any]]: """List supported providers and capabilities for UI/config discovery.""" out: list[dict[str, Any]] = [] for spec in PROVIDER_REGISTRY.values(): - out.append({ - "provider": spec.provider, - "display_name": spec.display_name, - "protocol": spec.protocol, - "default_base_url": spec.default_base_url, - "supports_tool_choice": spec.supports_tool_choice, - "default_max_tokens": spec.default_max_tokens, - "model_max_tokens": spec.model_max_tokens, - "aliases": [k for k, v in PROVIDER_ALIASES.items() if v == spec.provider], - }) + out.append( + { + "provider": spec.provider, + "display_name": spec.display_name, + "protocol": spec.protocol, + "default_base_url": spec.default_base_url, + "supports_tool_choice": spec.supports_tool_choice, + "default_max_tokens": spec.default_max_tokens, + "model_max_tokens": spec.model_max_tokens, + "aliases": [k for k, v in PROVIDER_ALIASES.items() if v == spec.provider], + } + ) return out @@ -1798,27 +1792,20 @@ def get_provider_manifest() -> list[dict[str, Any]]: for spec in PROVIDER_REGISTRY.values() } -PROVIDER_URLS: dict[str, str | None] = { - spec.provider: spec.default_base_url for spec in PROVIDER_REGISTRY.values() -} +PROVIDER_URLS: dict[str, str | None] = {spec.provider: spec.default_base_url for spec in PROVIDER_REGISTRY.values()} -TOOL_CHOICE_PROVIDERS = { - spec.provider for spec in PROVIDER_REGISTRY.values() if spec.supports_tool_choice -} +TOOL_CHOICE_PROVIDERS = {spec.provider for spec in PROVIDER_REGISTRY.values() if spec.supports_tool_choice} -MAX_TOKENS_BY_PROVIDER: dict[str, int] = { - spec.provider: spec.default_max_tokens for spec in PROVIDER_REGISTRY.values() -} +MAX_TOKENS_BY_PROVIDER: dict[str, int] = {spec.provider: spec.default_max_tokens for spec in PROVIDER_REGISTRY.values()} MAX_TOKENS_BY_MODEL: dict[str, int] = { - prefix: limit - for spec in PROVIDER_REGISTRY.values() - for prefix, limit in spec.model_max_tokens.items() + prefix: limit for spec in PROVIDER_REGISTRY.values() for prefix, limit in spec.model_max_tokens.items() } class LLMError(Exception): """Base exception for LLM client errors.""" + pass @@ -1920,7 +1907,7 @@ def create_llm_client( base_url=final_base_url, model=model, timeout=timeout, - supports_tool_choice=supports_tool_choice, + supports_tool_choice=supports_tool_choice ) else: # Default to OpenAI-compatible for unknown providers @@ -1937,6 +1924,7 @@ def create_llm_client( # High-level Convenience Functions # ============================================================================ + async def chat_complete( provider: str, api_key: str, @@ -1964,14 +1952,16 @@ async def chat_complete( ) return { - "choices": [{ - "message": { - "role": "assistant", - "content": response.content, - "tool_calls": response.tool_calls or None, - }, - "finish_reason": response.finish_reason or "stop", - }], + "choices": [ + { + "message": { + "role": "assistant", + "content": response.content, + "tool_calls": response.tool_calls or None, + }, + "finish_reason": response.finish_reason or "stop", + } + ], "model": response.model or model, "usage": response.usage or {}, } @@ -2010,14 +2000,16 @@ async def chat_stream( ) return { - "choices": [{ - "message": { - "role": "assistant", - "content": response.content, - "tool_calls": response.tool_calls or None, - }, - "finish_reason": response.finish_reason or "stop", - }], + "choices": [ + { + "message": { + "role": "assistant", + "content": response.content, + "tool_calls": response.tool_calls or None, + }, + "finish_reason": response.finish_reason or "stop", + } + ], "model": response.model or model, "usage": response.usage or {}, } diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index c633172..0d3e3ef 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, Component, ErrorInfo } from 'react'; +import React, { useState, useEffect, useRef, useCallback, Component, ErrorInfo } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -8,6 +8,14 @@ import type { FileBrowserApi } from '../components/FileBrowser'; import FileBrowser from '../components/FileBrowser'; import ChannelConfig from '../components/ChannelConfig'; import MarkdownRenderer from '../components/MarkdownRenderer'; + +const ChatInput = React.memo(({ onKeyDown, onPaste, placeholder, disabled, autoFocus, inputRef }: { + onKeyDown: (e: any) => void; onPaste: (e: any) => void; + placeholder: string; disabled: boolean; autoFocus: boolean; inputRef: React.RefObject; +}) => ( + +)); import PromptModal from '../components/PromptModal'; import { activityApi, agentApi, channelApi, enterpriseApi, fileApi, scheduleApi, skillApi, taskApi, triggerApi, uploadFileWithProgress } from '../services/api'; import { useAuthStore } from '../stores'; @@ -384,7 +392,7 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana }}>Reset to Global )} - + @@ -698,7 +706,6 @@ function AgentDetailInner() { queryKey: ['triggers', id], queryFn: () => triggerApi.list(id!), enabled: !!id && activeTab === 'aware', - refetchInterval: activeTab === 'aware' ? 5000 : false, }); // ── Aware tab data: focus.md ── @@ -726,7 +733,6 @@ function AgentDetailInner() { return all.filter((s: any) => s.source_channel === 'trigger'); }, enabled: !!id && activeTab === 'aware', - refetchInterval: activeTab === 'aware' ? 10000 : false, }); // ── Aware tab state ── @@ -776,7 +782,6 @@ function AgentDetailInner() { queryKey: ['activity', id], queryFn: () => activityApi.list(id!, 100), enabled: !!id && (activeTab === 'activityLog' || activeTab === 'status'), - refetchInterval: activeTab === 'activityLog' ? 10000 : false, }); // Chat history @@ -863,16 +868,8 @@ function AgentDetailInner() { }; const selectSession = async (sess: any) => { - // Close the existing WS before switching so its onmessage can no longer - // write stale streaming data into the new session's message list. - if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) { - wsRef.current.close(); - wsRef.current = null; - } setChatMessages([]); setHistoryMsgs([]); - setIsStreaming(false); - setIsWaiting(false); setActiveSession(sess); // Always load stored messages for the selected session const tkn = localStorage.getItem('token'); @@ -937,6 +934,7 @@ function AgentDetailInner() { interface ChatMsg { role: 'user' | 'assistant' | 'tool_call'; content: string; fileName?: string; toolName?: string; toolArgs?: any; toolStatus?: 'running' | 'done'; toolResult?: string; thinking?: string; imageUrl?: string; timestamp?: string; } const [chatMessages, setChatMessages] = useState([]); const [chatInput, setChatInput] = useState(''); + const chatInputRef2 = useRef(null); const [wsConnected, setWsConnected] = useState(false); const [uploading, setUploading] = useState(false); const [isWaiting, setIsWaiting] = useState(false); @@ -1041,15 +1039,9 @@ function AgentDetailInner() { // Reset state whenever the viewed agent changes useEffect(() => { - if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) { - wsRef.current.close(); - wsRef.current = null; - } setActiveSession(null); setChatMessages([]); setHistoryMsgs([]); - setIsStreaming(false); - setIsWaiting(false); setChatScope('mine'); setAgentExpired(false); settingsInitRef.current = false; @@ -1221,9 +1213,11 @@ function AgentDetailInner() { const sendChatMsg = () => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; - if (!chatInput.trim() && attachedFiles.length === 0) return; - - let userMsg = chatInput.trim(); + const _inputEl = chatInputRef.current; + if (!_inputEl) return; + const _inputVal = _inputEl.value.trim(); + if (!_inputVal && attachedFiles.length === 0) return; + let userMsg = _inputVal; let contentForLLM = userMsg; let displayFiles = ''; @@ -1270,7 +1264,8 @@ function AgentDetailInner() { file_name: attachedFiles.map(f => f.name).join(', ') })); - setChatInput(''); + if (chatInputRef.current) chatInputRef.current.value = ''; + setChatInput(''); setAttachedFiles([]); }; @@ -1517,7 +1512,6 @@ function AgentDetailInner() { queryKey: ['task-logs', id, selectedTaskId], queryFn: () => taskApi.getLogs(id!, selectedTaskId!), enabled: !!id && !!selectedTaskId, - refetchInterval: selectedTaskId ? 3000 : false, }); // Schedule execution history (selectedTaskId format: 'sched-{uuid}') @@ -1717,49 +1711,49 @@ function AgentDetailInner() { {/* Metric cards */}
-
📋 {t('agent.tabs.status')}
+
{t('agent.tabs.status')}
{t(`agent.status.${statusKey}`)}
-
🗓️ {t('agent.settings.today')} Token
+
{t('agent.settings.today')} Token
{formatTokens(agent.tokens_used_today)}
{agent.max_tokens_per_day &&
{t('agent.settings.noLimit')} {formatTokens(agent.max_tokens_per_day)}
}
-
📅 {t('agent.settings.month')} Token
+
{t('agent.settings.month')} Token
{formatTokens(agent.tokens_used_month)}
{agent.max_tokens_per_month &&
{t('agent.settings.noLimit')} {formatTokens(agent.max_tokens_per_month)}
}
{/* Native agent metrics */} {(agent as any)?.agent_type !== 'openclaw' && (<>
-
{t('agent.status.llmCallsToday')}
+
LLM Calls Today
{((agent as any).llm_calls_today || 0).toLocaleString()}
-
{t('agent.status.max')}: {((agent as any).max_llm_calls_per_day || 100).toLocaleString()}
+
Max: {((agent as any).max_llm_calls_per_day || 100).toLocaleString()}
-
{t('agent.status.totalToken')}
+
Total Token
{formatTokens((agent as any).tokens_used_total || 0)}
{metrics && ( <>
-
✅ {t('agent.tasks.done')}
+
{t('agent.tasks.done')}
{metrics.tasks?.done || 0}/{metrics.tasks?.total || 0}
{metrics.tasks?.completion_rate || 0}%
-
{t('agent.status.pending')}
+
Pending
0 ? 'var(--warning)' : 'inherit' }}>{metrics.approvals?.pending || 0}
- {t('agent.status.24hActions')} + {i18n.language?.startsWith('zh') ? '24h 活动' : '24h Actions'} - {t('agent.status.24hActionsTooltip')} + {i18n.language?.startsWith('zh') ? '过去 24 小时内该 Agent 的所有操作记录,包括对话、工具调用、任务执行等' : 'Total recorded operations in the past 24 hours, including chats, tool calls, task executions, etc.'}
{metrics.activity?.actions_last_24h || 0}
@@ -1770,12 +1764,12 @@ function AgentDetailInner() { {(agent as any)?.agent_type === 'openclaw' && (
- {t('agent.openclaw.lastSeen')} + {i18n.language?.startsWith('zh') ? '最近连接' : 'Last Seen'}
{(agent as any).openclaw_last_seen ? new Date((agent as any).openclaw_last_seen).toLocaleString() - : t('agent.openclaw.notConnected')} + : (i18n.language?.startsWith('zh') ? '尚未连接' : 'Not connected')}
)} @@ -1784,14 +1778,14 @@ function AgentDetailInner() { {/* Agent Profile & Model Info */}
-

{t('agent.profile.title')}

+

📋 Agent Profile

{t('agent.fields.role')} {agent.role_description || '—'}
- {t('agent.profile.created')} + Created {agent.created_at ? formatDate(agent.created_at) : '—'}
{(agent as any).creator_username && ( @@ -1801,29 +1795,29 @@ function AgentDetailInner() {
)}
- {t('agent.profile.lastActive')} + Last Active {agent.last_active_at ? formatDate(agent.last_active_at) : '—'}
- {t('agent.profile.timezone')} + 🌐 Timezone {(agent as any).effective_timezone || agent.timezone || 'UTC'}
{(agent as any)?.agent_type !== 'openclaw' ? (
-

{t('agent.modelConfig.title')}

+

Model Config

- {t('agent.modelConfig.model')} + Model {modelLabel}
- {t('agent.modelConfig.provider')} + Provider {modelProvider}
- {t('agent.modelConfig.contextRounds')} + Context Rounds {(agent as any).context_window_size || 100}
@@ -1831,11 +1825,11 @@ function AgentDetailInner() { ) : (

- {t('agent.openclaw.connection')} + {i18n.language?.startsWith('zh') ? 'OpenClaw 连接' : 'OpenClaw Connection'}

- {t('agent.openclaw.type')} + {i18n.language?.startsWith('zh') ? '类型' : 'Type'}
- {t('agent.openclaw.lastSeen')} + {i18n.language?.startsWith('zh') ? '最近连接' : 'Last Seen'} {(agent as any).openclaw_last_seen ? new Date((agent as any).openclaw_last_seen).toLocaleString() - : t('agent.openclaw.never')} + : (i18n.language?.startsWith('zh') ? '尚未连接' : 'Never')}
- {t('agent.openclaw.model')} - {t('agent.openclaw.managedBy')} + {i18n.language?.startsWith('zh') ? '模型' : 'Model'} + {i18n.language?.startsWith('zh') ? '由 OpenClaw 实例管理' : 'Managed by OpenClaw'}
@@ -1883,7 +1877,7 @@ function AgentDetailInner() { {/* Quick Actions */}
- {(agent as any)?.agent_type !== 'openclaw' && } + {(agent as any)?.agent_type !== 'openclaw' && }
@@ -2198,7 +2192,7 @@ function AgentDetailInner() { className="btn btn-ghost" style={{ width: '100%', fontSize: '12px', color: 'var(--text-tertiary)', padding: '8px', marginTop: '4px' }} > - {t('agent.aware.showMore', { count: hiddenActiveCount })} + {i18n.language?.startsWith('zh') ? `显示更多 ${hiddenActiveCount} 项...` : `Show ${hiddenActiveCount} more...`} )} {showAllFocus && activeFocusItems.length > SECTION_PAGE_SIZE && ( @@ -2207,7 +2201,7 @@ function AgentDetailInner() { className="btn btn-ghost" style={{ width: '100%', fontSize: '12px', color: 'var(--text-tertiary)', padding: '8px', marginTop: '4px' }} > - {t('agent.aware.showLess')} + {i18n.language?.startsWith('zh') ? '收起' : 'Show less'} )} @@ -2225,8 +2219,8 @@ function AgentDetailInner() { }} > {showCompletedFocus - ? t('agent.aware.hideCompleted') - : t('agent.aware.showCompleted', { count: completedFocusItems.length }) + ? (i18n.language?.startsWith('zh') ? '隐藏已完成' : 'Hide completed') + : (i18n.language?.startsWith('zh') ? `显示 ${completedFocusItems.length} 项已完成` : `Show ${completedFocusItems.length} completed`) } {showCompletedFocus && completedFocusItems.map(renderFocusItem)} @@ -2892,12 +2886,12 @@ function AgentDetailInner() {
{isAdmin && ( )}
@@ -2909,7 +2903,7 @@ function AgentDetailInner() { style={{ width: '100%', padding: '5px 8px', background: 'none', border: '1px solid var(--border-subtle)', borderRadius: '6px', cursor: 'pointer', fontSize: '12px', color: 'var(--text-secondary)', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '6px' }} onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-secondary)'; e.currentTarget.style.color = 'var(--text-primary)'; }} onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = 'var(--text-secondary)'; }}> - + {t('agent.chat.newSession')} + + New Session
)} @@ -2920,7 +2914,7 @@ function AgentDetailInner() { sessionsLoading ? (
{t('common.loading')}
) : sessions.length === 0 ? ( -
{t('agent.chat.noSessionsYet')}
{t('agent.chat.clickToStart')}
+
No sessions yet.
Click "+ New Session" to start.
) : sessions.map((s: any) => { const isActive = activeSession?.id === s.id; const isOwn = s.user_id === String(currentUser?.id); @@ -3025,8 +3019,8 @@ function AgentDetailInner() {
{!activeSession ? (
-
{t('agent.chat.noSessionSelected')}
- +
No session selected
+
) : (activeSession.user_id && currentUser && activeSession.user_id !== String(currentUser.id)) || activeSession.source_channel === 'agent' || activeSession.participant_type === 'agent' ? ( /* ── Read-only history view (other user's session or agent-to-agent) ── */ @@ -3313,17 +3307,19 @@ function AgentDetailInner() {
)} - setChatInput(e.target.value)} + { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); sendChatMsg(); } }} onPaste={handlePaste} placeholder={!wsConnected && (!activeSession?.user_id || !currentUser || activeSession.user_id === String(currentUser?.id)) ? 'Connecting...' : attachedFiles.length > 0 ? t('agent.chat.askAboutFile', { name: attachedFiles.length === 1 ? attachedFiles[0].name : `${attachedFiles.length} files` }) : t('chat.placeholder')} - disabled={!wsConnected || isWaiting || isStreaming} style={{ flex: 1 }} autoFocus /> + disabled={!wsConnected || isWaiting || isStreaming} /> {(isStreaming || isWaiting) ? ( ) : ( - + )} @@ -3387,9 +3383,9 @@ function AgentDetailInner() { {(logFilter === 'backend' || logFilter === 'heartbeat' || logFilter === 'schedule' || logFilter === 'messages') && ( <> - {filterBtn('heartbeat', '💓 ' + t('agent.mind.heartbeatTitle'))} - {filterBtn('schedule', '⏰ ' + t('agent.activityLog.scheduleCron'), true)} - {filterBtn('messages', '📨 ' + t('agent.activityLog.messages'), true)} + {filterBtn('heartbeat', '💓 Heartbeat', true)} + {filterBtn('schedule', '⏰ Schedule/Cron', true)} + {filterBtn('messages', '📨 Messages', true)} )} )} @@ -3464,9 +3460,8 @@ function AgentDetailInner() { const { data: approvals = [], refetch: refetchApprovals } = useQuery({ queryKey: ['agent-approvals', id], queryFn: () => fetchAuth(`/agents/${id}/approvals`), - enabled: !!id, - refetchInterval: 15000, - }); + enabled: !!id, + }); const resolveMut = useMutation({ mutationFn: async ({ approvalId, action }: { approvalId: string; action: string }) => { const token = localStorage.getItem('token'); @@ -3918,8 +3913,8 @@ function AgentDetailInner() { {/* Permission Management */} {(() => { const scopeLabels: Record = { - company: '🏢 ' + t('agent.settings.perm.companyWide', 'Company-wide'), - user: '👤 ' + t('agent.settings.perm.onlyMe', 'Only Me'), + company: '🏢 ' + t('agent.settings.perm.company', 'Company-wide'), + user: '👤 ' + t('agent.settings.perm.selfOnly', 'Only Me'), }; const handleScopeChange = async (newScope: string) => { @@ -3994,8 +3989,8 @@ function AgentDetailInner() {
{scopeLabels[scope]}
- {scope === 'company' && t('agent.settings.perm.companyWideDesc', 'All users in the organization can use this agent')} - {scope === 'user' && t('agent.settings.perm.onlyMeDesc', 'Only the creator can use this agent')} + {scope === 'company' && t('agent.settings.perm.companyDesc', 'All users in the organization can use this agent')} + {scope === 'user' && t('agent.settings.perm.selfDesc', 'Only the creator can use this agent')}
@@ -4006,11 +4001,11 @@ function AgentDetailInner() { {currentScope === 'company' && isOwner && (
- {[{ val: 'use', label: '👁️ ' + t('agent.settings.perm.useAccess', 'Use'), desc: t('agent.settings.perm.useAccessDesc', 'Task, Chat, Tools, Skills, Workspace') }, - { val: 'manage', label: '⚙️ ' + t('agent.settings.perm.manageAccess', 'Manage'), desc: t('agent.settings.perm.manageAccessDesc', 'Full access including Settings, Mind, Relationships') }].map(opt => ( + {[{ val: 'use', label: '👁️ ' + t('agent.settings.perm.useLevel', 'Use'), desc: t('agent.settings.perm.useDesc', 'Task, Chat, Tools, Skills, Workspace') }, + { val: 'manage', label: '⚙️ ' + t('agent.settings.perm.manageLevel', 'Manage'), desc: t('agent.settings.perm.manageDesc', 'Full access including Settings, Mind, Relationships') }].map(opt => (