重构交互类和展示类工具流程

This commit is contained in:
minorQAQ committed 2026-08-12 21:50:02 +08:00
1 parent 9acb38c9e6
commit 2a0875452f
33 files changed
+2645 -3534

No files matched your search

+132 -55
View File
@@ -4,7 +4,7 @@
### 一个可本地运行的多模态 Agent 桌面应用 —— 真正能"动手"操作你电脑的 AI 助手
基于 **LangGraph** 构建,具备 GUI 自动化、浏览器控制、终端执行、RAG 知识库、邮件收发、图像生成、语音对话、数据库操作、定时任务等 19 项工具,并提供 Electron 桌面端一键安装体验。
基于 **LangGraph** 构建,具备 GUI 自动化、浏览器控制、终端执行、RAG 知识库、邮件收发、图像生成、语音对话、数据库操作、定时任务等 20 项工具,并提供 Electron 桌面端一键安装体验。
<!-- 主界面截图占位 -->
<img src="./git_image/banner.png" alt="minor Agent 主界面" width="920">
@@ -22,10 +22,11 @@
> 🎓 **项目初衷**:这是一个 LangChain + LangGraph 的学习项目。基于两者构建 Agent 工作流,结构清晰、上手容易、扩展方便,适合作为 Agent 开发的参考实现。
- 🖥️ **真能操作桌面**:截图 → 视觉定位 → 键鼠模拟,闭环完成 ERP 录入、表单填写、软件操控
- 🧩 **19 项内置工具**:从终端命令到邮件收发,从 PPT 制作到图像生成,覆盖真实办公场景
- 🧩 **20 项内置工具**:从终端命令到邮件收发,从 PPT 制作到图像生成,覆盖真实办公场景
- 🏠 **完全本地运行**:Electron 打包,数据不出本机;后端 FastAPI + 前端零延迟直读磁盘
- 🎨 **可深度定制**:主题 / 字体 / 字号 / 背景 / 工作空间 / 提示词分层架构,全部可调
- 🔁 **工程级稳定性**:循环检测、上下文压缩、人工确认、轨迹回放,对抗 LLM 的"幻觉操作"
- 🔁 **工程级稳定性**:循环检测、上下文压缩、阻塞式人工确认、轨迹回放,对抗 LLM 的"幻觉操作"
- 💾 **存储双后端(接口隔离)**:会话与定时任务存储统一接口驱动,**JSON 可视化 / MySQL** 一键切换、不双写;新增存储方式只需实现接口,消费方无感知后端
> 💡 项目演进:`Web 在线应用` → `Electron 桌面应用`。源码同一套 `src/`,既可由 `uvicorn` 作 Web 服务启动,也可由 Electron 主进程拉起,**双模式共用**。
@@ -38,7 +39,7 @@
## ✨ 功能亮点
### 🛠️ 19 项工具能力
### 🛠️ 20 项工具能力
| 工具 | 文件 | 能力 |
|------|------|------|
@@ -50,17 +51,18 @@
| 📄 **doc** | `tools/doc.py` | 创建/读取/编辑 docx / xlsx / pptx / pdf / txt |
| 📂 **directory_listing** | `tools/directory_listing.py` | 递归列目录、文件元信息 |
| 📨 **send_file** | `tools/send_file.py` | 以邮件附件形式发送本地文件 |
| 🔍 **web_search** | `tools/web_search.py` | 联网搜索(Tavily / Firecrawl) |
| 🔍 **web_search** | `tools/web_search.py` | 联网搜索(Tavily / Firecrawl / 火山引擎) |
| 🗄️ **text2sql** | `tools/text2sql.py` | 自然语言/SQL 操作数据库:查表结构、查询、增删改 |
| 📚 **rag** | `tools/rag.py` | ChromaDB 向量检索 + Reranker 重排序 |
| 🎨 **image_gen** | `tools/image_gen.py` | 调用 Z-Image-Turbo 生成图片 |
| ✅ **todo_list** | `tools/todo_list.py` | 多步任务规划与进度跟踪 |
| 🤝 **human_interaction** | `tools/human_interaction.py` | 主动询问用户、收集信息 |
| 🤝 **human_interaction** | `tools/human_interaction.py` | 阻塞式询问用户:收集信息 / 多选一,答案作为普通工具返回值 |
| 🤖 **agent_call** | `tools/agent_call.py` | 委派子 Agent 处理子任务 |
| 🧭 **skill_router** | `tools/skill_router.py` | 匹配并路由到预定义 Skill 流程 |
| 📅 **date_query** | `tools/date_query.py` | 当前日期时间查询 |
| ⏰ **cron_manager** | `tools/cron_manager.py` | 定时任务管理:查看/新增/修改/删除定时任务,支持 cron/间隔/一次性触发 |
| 🗣️ **streaming_client** | `tts/streaming_client.py` | 流式 TTS 语音合成客户端 |
| 📊 **hard_excel_read** | `tools/hard_excel_read.py` | 复杂 Excel 表结构读取:前 N 行原始数据 + 维度/合并区/数据区范围建议,供大模型解析结构 |
| 📥 **excel2sql** | `tools/excel2sql.py` | 按大模型给定的数据行列范围,将 Excel 数据区流式批量入库 MySQL(自动建库建表、表注释) |
### 🎯 区别于其他 Agent 项目的核心设计
@@ -70,7 +72,7 @@
| **视觉反馈** | 仅返回坐标文本 | **截图作为视觉闭环**注入上下文,多模态 LLM 看到真实屏幕状态 |
| **坐标定位** | 逐元素请求 | **批量定位** `_batch_ground_elements`:N 个元素 1 次子模型调用 |
| **循环失控** | 无防护,token 烧穿 | **两级循环检测**:连续 3 次同工具警告反思 → 连续 5 次强制终止 |
| **上下文膨胀** | 简单截断 | **图内分级压缩**:50% 窗口轻量压缩 / 75% 窗口全量压缩,每步 LLM 调用后检查 |
| **上下文膨胀** | 简单截断 | **图内分级压缩**:token 超 `窗口×COMPRESS_RATE` 触发,压缩历史工具调用为累积摘要,每步 LLM 调用后检查 |
| **部署形态** | 仅 Web / 仅 API | **同一套源码双模式**:`uvicorn` Web 服务 ↔ Electron 桌面应用 |
| **文件操作** | HTTP 上传下载 | 桌面端走 **IPC 直读磁盘**,零延迟;Web 端走 `/api/fs/*` |
| **提示词** | 单层 system prompt | **四层提示词架构**:系统级 / 工具描述 / 运行时注入 / 内部子模型 |
@@ -84,7 +86,7 @@ Agent 运行过程中的关键交互界面,支持文件从任意位置**拖拽
<img src="./git_image/运行-人机交互.png" alt="人机交互确认" width="360">
<img src="./git_image/运行-todolist.png" alt="任务规划 TodoList" width="360">
<br>
<sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)弹出确认浮窗,用户可同意、拒绝或修改指令&emsp;|&emsp;<b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表</sub>
<sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)与信息收集以浮窗形式实时弹出,用户可同意、拒绝、跳过或补充信息;**拒绝也只是一次工具返回值**,Agent 自行调整方案继续,不会结束本轮&emsp;|&emsp;<b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表</sub>
</p>
<p align="center">
@@ -136,7 +138,9 @@ Agent 运行过程中的关键交互界面,支持文件从任意位置**拖拽
│ FastAPI 后端 (web/server.py) │
│ 会话管理 · 多模态收发 · SSE 流式 · 文件服务 · 工具调用推送 │
└───────────────────────────────┬──────────────────────────────┘
│ execute_agent()
│ turn_runner.start_turn()
│(每会话一个后台线程,HTTP 请求
│ 挂载等待;断连不影响执行)
▼
┌──────────────────────────────────────────────────────────────┐
│ Agent 编排层 (agents/agent_runtime.py) │
@@ -147,36 +151,45 @@ Agent 运行过程中的关键交互界面,支持文件从任意位置**拖拽
┌──────────────────────────────────────────────────────────────┐
│ LangGraph ReAct 图 (core/graph.py) │
│ agent 节点 → tools 节点 → process_tool_artifact 节点 │
│ 内含: 反思注入 · 压缩 · 循环检测 · 路由 · 人工确认 │
│ 内含: 反思注入 · 压缩 · 循环检测 · 路由 │
│ 人机交互: 阻塞式普通工具(core/human_request.py) │
└───────────────────────────────┬──────────────────────────────┘
│ 调用
▼
┌──────────────────────────────────────────────────────────────┐
│ 19 项工具 (tools/*.py) · 5 项技能 (skills/) │
│ 20 项工具 (tools/*.py) · 5 项技能 (skills/) │
└──────────────────────────────────────────────────────────────┘
```
### ReAct 循环(图内)
```
START → [agent: call_model] ──有 tool_calls──▶ [tools: ToolNode] ──▶ [process_tool_artifact]
▲ ◀────────────────────────┘
│ 无 tool_calls
▼
START → [agent: call_model] ──有 tool_calls──▶ [tools: 并行工具节点] ──▶ [process_tool_artifact] ──▶ [compress]
▲ ▲ │ │
│ 无 tool_calls │ ◀────────────────────────────┘
▼ └── 子 Agent 结果 / 工具返回值注入 ◀── 并行工具节点(线程池)
END
```
- **`call_model`**(`core/nodes.py`):注入反思提示 → LLM 调用 → 每步检查压缩 → 提取 thinking
- **`should_continue`**(`core/routing.py`):含 tool_calls → 检查确认策略 / 循环检测 → 路由到 `tools` 或 `END`
- **`call_model`**(`core/nodes.py`):注入反思提示 → LLM 调用 → 每步检查压缩(token 超阈值标记)→ 提取 thinking
- **`should_continue`**(`core/routing.py`):含 tool_calls → 循环检测 → 路由到 `tools` 或 `END`(需人工确认的工具不在此暂停,而是在工具执行钳点阻塞征求用户意见,见「阻塞式人机交互」)
- **`process_tool_artifact`**:gui 截图作为 synthetic HumanMessage 注入,实现视觉闭环
- **`compress`**(`core/nodes.py`):token 超 `窗口×COMPRESS_RATE` 时,将历史工具调用压缩为累积摘要(见「实现细节」)
### 多 Agent 模式
多 Agent 并非独立图,而是**同一张 react 图内的工具级并行**:`tools` 节点由 `make_parallel_tool_node`(线程池)取代 ToolNode,一轮内的多个工具调用并行执行;其中 `agent_call` 工具会以主 Agent 当前消息快照启动**子 Agent 的独立图实例**执行子任务,结果以工具返回值注入主 Agent 上下文。子 Agent 内的人机交互同样为阻塞式——在子图线程中等待浮窗应答,完成后自然回到主图:
```
START → [supervisor] ──main完成──▶ [parallel_sub_agents] ──并行完成──▶ [supervisor]
│ │
└─ 子Agent结果 tag_message 注入主Agent ◄┘
有 agent_call → 循环;无 → END
[agent] ──有 agent_call──▶ [tools: parallel_tool_node(线程池)]
│
├─ 普通工具:直接并行执行
└─ agent_call:spawn 子 Agent Runtime → 子图 invoke
│(子图内 human_interaction 阻塞等待
│ 浮窗应答,答案作为工具返回值)
└─ 结果作为 ToolMessage 注入主 Agent ◄┐
▲ │
└──────────────── 有 agent_call → 继续循环;无 → END ──────────────────────┘
```
---
@@ -193,27 +206,29 @@ Agent/
├── src/
│ ├── agent/ # 🧠 Agent 核心
│ │ ├── agents/
│ │ │ └── agent_runtime.py # 编排层入口:execute_agent / continue_after_human_action
│ │ │ └── agent_runtime.py # 编排层入口:execute_agent(动态 Agent 工厂)
│ │ ├── core/
│ │ │ ├── graph.py # LangGraph 构建(单 Agent + 多 Agent)
│ │ │ ├── nodes.py # ReAct 节点:call_model / process_tool_artifact / 压缩
│ │ │ ├── runtime.py # 图执行器:消息拼装、历史加载、轨迹落盘
│ │ │ ├── routing.py # should_continue 路由 + 强制终止
│ │ │ ├── state.py # Graph 状态定义(messages / agent_mode)
│ │ │ ├── loop_detector.py # 循环检测:重复工具 / TodoList 停滞,两级响应
│ │ │ ├── loop_detector.py # 循环检测:重复工具调用,两级响应
│ │ │ ├── human_request.py # ★ 人工请求注册表:交互类工具的通用阻塞通道(ask_human)
│ │ │ ├── turn_runner.py # ★ 后台线程轮次执行器(解耦 HTTP 与图执行生命周期)
│ │ │ ├── llm.py # ChatQwen 多模态封装(图像/附件/非标准 tool_call)
│ │ │ ├── tool_policy.py # 工具执行策略(直执 / 需确认)
│ │ │ ├── approvals.py # 待确认操作注册与 UI 元数据
│ │ │ ├── storage.py # ★ 统一存储层:接口 + JSON/MySQL 双实现 + 工厂 + scope
│ │ │ └── config_manager.py# 配置读写:agent/tool/env/gui/model/theme
│ │ ├── memory/
│ │ │ └── system_prompt.py # ★ 四层提示词:MAIN / PLAN / REFLECTION / VISION...
│ │ ├── tools/ # 19 项工具(见上表)
│ │ ├── tools/ # 20 项工具(见上表)
│ │ │ ├── ...(gui / browser_control / terminal_execute / email / doc / web_search / text2sql / rag / cron_manager 等)
│ │ ├── cron/ # ⏰ 定时任务引擎
│ │ │ ├── models.py # 数据模型(CronTask / Trigger)
│ │ │ ├── storage.py # 持久化层(CronTaskRepository,JSON / MySQL)
│ │ │ ├── storage.py # 转发层(实现统一在 core/storage.py)
│ │ │ ├── scheduler.py # 调度计算与触发扫描
│ │ │ ├── runner.py # 任务执行器(子进程)
│ │ │ ├── runner.py # 任务执行器(子进程,切换存储 cron 作用域)
│ │ │ └── live.py # 常驻调度循环
│ │ ├── skills/ # 5 项技能(skill.json + skill.md)
│ │ │ ├── netease_mail_read/
@@ -224,8 +239,8 @@ Agent/
│ │ ├── tts/
│ │ │ └── streaming_client.py # 流式 TTS 客户端(SSE 收音频块)
│ │ ├── history/
│ │ │ ├── session_storage.py # 会话与消息持久化(JSON / MySQL 双后端)
│ │ │ └── tool_call_recorder.py # 每轮工具调用轨迹记录 (turn_{id}.json)
│ │ │ ├── session_storage.py # 转发层(实现统一在 core/storage.py)
│ │ │ └── tool_call_recorder.py # 每轮工具调用轨迹记录 (tool_{turn_id}.json)
│ │ ├── utils/ # env_utils / agent_utils / image_utils / ppt_utils / tool_call_utils
│ │ └── config/ # ★ 配置文件目录
│ │ ├── env_config.json # 运行时配置(首次向导生成,勿手动改)
@@ -237,12 +252,12 @@ Agent/
│ │ └── workspace_config.json # 工作空间列表与当前选择
│ │
│ └── web/ # 🎨 前端 UI
│ ├── server.py # FastAPI 后端(50+ 路由)
│ ├── server.py # FastAPI 后端(75+ 路由)
│ ├── ui_session.py # UI 会话状态管理
│ ├── index.html # 主页面(侧边栏/聊天/编辑/Agent面板/操作栏)
│ ├── app.js / app.css # 入口
│ ├── js/ # 28 个前端模块(见开发手册)
│ ├── css/ # 16 个样式模块 + variables.css 主题变量
│ ├── js/ # 30 个前端模块(见开发手册)
│ ├── css/ # 18 个样式模块 + variables.css 主题变量
│ ├── packages/ # Monaco Editor 本地 npm 包
│ └── image/ # SVG 图标资源
│
@@ -361,22 +376,21 @@ cloudflared tunnel --url http://localhost:8765
|------|--------|------|
| `models[]` | `SETUP_REQUIRED` | LLM 列表:`{id, name, model, api_key, base_url, timeout, max_retries}` |
| `gui_model_id` | `""` | GUI 视觉定位专用模型(留空则回退主模型) |
| `WORKING_DIR` | `.` | 工作根目录(桌面端自动设为安装目录) |
| `WORKSPACE_DIR` | `workspace` | 默认工作空间子目录 |
| `WORKING_DIR` | 项目根目录 | 工作根目录(桌面端自动设为安装目录) |
| `WORKSPACE_DIR` | `""` | 工作空间目录(相对 WORKING_DIR),留空则使用 WORKING_DIR |
| `USER_PYTHON_PATH` | `""` | 指定 Python 解释器路径 |
| `LLM_CONTEXT_WINDOW` | `262144` | 上下文窗口大小(token) |
| `MINI_COMPRESS_RATE` | `0.4` | 轻量压缩阈值(占窗口 40% 触发) |
| `HARD_COMPRESS_RATE` | `0.7` | 全量压缩阈值(占窗口 70% 触发) |
| `CURRENT_TURNS` | `4` | 短期记忆保留的最近工具轮数 |
| `COMPRESS_RATE` | `0.6` | 上下文压缩阈值比例(0~1),token 用量超过 `窗口×该值` 时触发压缩 |
| `THINKING_LEVEL` | `"low"` | 深度思考档位:low / high / xhigh / max / ultra(xhigh 及以上启用深度思考) |
| `IMG_SIZE` | `768` | 截图短边尺寸 |
| `GROUNDING_WIDTH/HEIGHT` | `1000` | 视觉定位输入分辨率 |
| `LOOP_DETECT_TODOLIST_STALE_ROUNDS` | `20` | TodoList 停滞多少轮触发反思 |
| `LOOP_DETECT_REPEATED_TOOL_WARN` | `3` | 连续同工具同参多少次警告 |
| `LOOP_DETECT_REPEATED_TOOL_END` | `5` | 连续同工具同参多少次强制终止 |
| `RAG_CHUNK_SIZE / OVERLAP` | `500 / 50` | RAG 分块大小与重叠 |
| `IMAGE_GEN_URL` | `http://localhost:8904` | 图像生成服务地址 |
| `SEND_FILE_SIZE_LIMIT` | `30` | 发送文件大小上限(MB) |
| `WEB_SEARCH_API_KEY` | `""` | 联网搜索 API Key |
| `WEB_SEARCH_ENGINE` | `"tavily"` | 搜索引擎选择(tavily / firecrawl) |
| `WEB_SEARCH_ENGINE` | `"tavily"` | 搜索引擎选择(tavily / firecrawl / volcengine) |
| `STORAGE_BACKEND` | `"json"` | 存储后端(json / mysql) |
| `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE` | `""` | MySQL 连接参数(STORAGE_BACKEND=mysql 时生效) |
| `TOOL_TIMEOUT` | `300` | 工具执行超时时间(秒) |
@@ -433,21 +447,52 @@ cloudflared tunnel --url http://localhost:8765
```
每次 LLM 调用后读取 response.usage_metadata.total_tokens
├─ > 50% 窗口 → 轻量压缩:保留本轮首个 agent 动作
├─ > 75% 窗口 → 全量压缩:仅保留当前 user 消息
└─ 就地替换 state["messages"] = [SystemMessage(摘要) + 保留部分]
├─ 未超阈值 → 不处理,继续下一轮
├─ 主 Agent 超阈值 → 标记压缩,由 compress 节点统一执行:
│ 将注入的历史工具调用与返回压缩为累积摘要(RemoveMessage 移除已覆盖消息)
└─ 子 Agent 超阈值 → 注入整理提示词,整理进度后直接返回主 Agent(子 Agent 不压缩)
```
压缩发生在 `call_model` 内部(图内每步),而非图外一次性处理,**子 Agent 自动受益**。
- **触发**:`call_model` 内每步检查 `total_tokens`,超过 `LLM_CONTEXT_WINDOW × COMPRESS_RATE`(默认 0.6)即标记压缩
- **执行**:压缩发生在图内 `compress` 节点(`process_tool_artifact` 之后),只压缩注入的历史工具上下文,系统提示词与对话历史保留,摘要按压缩游标累积
- **子 Agent**:不做压缩,超限时整理任务进度直接返回主 Agent,避免上下文继续膨胀
### 3. 循环检测两级响应
| 级别 | 触发条件 | 响应 |
|------|----------|------|
| ⚠️ 警告 | 连续 3 次同工具同参 / TodoList 停滞 20 轮 | 注入反思提示引导模型自我纠偏 |
| ⚠️ 警告 | 连续 3 次同工具同参 | 注入反思提示引导模型自我纠偏 |
| 🛑 终止 | 连续 5 次同工具同参 | `should_force_end()` 返回 END,向前端输出错误报告 |
### 4. GUI 动作序列化(性能关键)
### 4. 阻塞式人机交互(交互类 / 展示类工具的统一模式)
人机交互不再"暂停图 + 存快照 + 续跑",而是**阻塞式普通工具**:工具在图上线程中等待前端浮窗应答,答案(同意 / 拒绝 / 跳过 / 补充信息)只是普通工具返回值,图自然继续——**拒绝不会结束整轮**,Agent 自行调整方案。
```
LLM 发出 human_interaction / 需确认工具调用
→ tools 节点执行工具
→ human_request.ask_human() 阻塞等待(注册到请求表,随 live snapshot 推送前端)
→ 前端浮窗出现,工具调用行显示"运行中"
→ 用户点击 → POST /api/human-action → 写入答案并唤醒
→ 工具返回答案文本 → ToolMessage → 图继续(无快照重放、无续跑状态机)
```
| 设计点 | 传统做法(本项目旧版) | 阻塞式(当前) |
|--------|----------------------|----------------|
| 图执行 | 请求线程内同步 invoke | 后台线程(`core/turn_runner.py`),断连/关页不影响执行,双发请求挂同一轮 |
| 等待答案 | 图 END + 消息快照存 `PENDING_TOOL_APPROVALS` | 工具内 `threading.Event` 阻塞(`core/human_request.py`) |
| 决策处理 | `continue_after_human_action` 重放快照续跑 | 答案即返回值,无续跑路径 |
| 拒绝 | 结束整轮(等同手动暂停) | 仅返回值,Agent 调整后继续 |
| 工具列表 | 确认时清空重建、续跑时重放 | 自然展示:等待中 `running` → 应答后 `done` |
| 子 Agent 交互 | 异常冒泡(`SubAgentPendingError`)+ 恢复状态机 | 子图线程内直接阻塞,完成自然回主图 |
| 无人值守(cron) | 悬死靠超时兜底 | `ask_human` 检测 headless 立即返回默认答案 |
**两类工具的扩展模式**(新增能力零框架改动):
- **交互类工具**(如 `human_interaction`):工具内调一次 `ask_human(meta)` 阻塞,自己格式化返回值
- **展示类工具**(如 `todo_list`):纯文本返回、不维护全局 store,前端从通用 live 记录(工具调用的 args/结果)按工具名写一个小 adapter 渲染浮窗(如 `js/todo.js` 的 `updateTodoOverlayFromRecords`)
### 5. GUI 动作序列化(性能关键)
| 模式 | N 个动作的 LLM 调用次数 |
|------|------------------------|
@@ -456,7 +501,7 @@ cloudflared tunnel --url http://localhost:8765
通过 `MAIN_SYSTEM_PROMPT` + `REFLECTION_PROMPT` 双重强调"同页操作必须合并为一次 `gui` 调用"实现。
### 5. 双模式源码共用
### 6. 双模式源码共用
`src/web/` 同一套代码两种部署:
@@ -469,6 +514,36 @@ cloudflared tunnel --url http://localhost:8765
前端通过 `window.electronAPI?.isElectron` 自动适配。
### 7. 存储双后端(JSON 可视化 / MySQL,统一接口隔离)
会话历史与定时任务存储统一在 **`agent/core/storage.py`**:接口(Protocol)驱动、双后端实现、**消费方零感知后端选择**(不判断 `STORAGE_BACKEND`)。
**接口与实现**
- `ConversationRecordRepository`(对话消息 / 工具调用 / 轮次元数据 / 会话生命周期,16 方法)与 `TaskConfigRepository`(cron 任务配置,6 方法)两个 Protocol
- 每后端一个实现类:`JsonRecordRepository` / `MysqlRecordRepository`(含会话与 cron 双作用域)、`JsonTaskConfigRepository` / `MysqlTaskConfigRepository`
- **新增存储后端只需实现上述接口并在工厂注册一行**;消费方新增功能/字段无需改动存储层(消息原样 JSON 存储、工具记录 meta 全量透传)
**作用域(scope)**
- `session`(默认):主进程普通会话,JSON 落盘 `history/sessions/{session_id}/`,MySQL 用 `sessions / session_messages / session_tool_calls` 三表
- `cron`:定时任务,JSON 落盘 `history/cron/{task_id}/`,MySQL 用 `cron_messages / cron_tool_calls` 两表;cron 子进程切换存储作用域后**直写**记录,主进程经 cron 作用域仓库读取
| 后端 | 存储位置 | 说明 |
|------|----------|------|
| **json**(默认) | `history/sessions/{session_id}/` | 消息与工具调用以人类可读 JSON 文件落盘(`turn_{turn_id}.json` / `tool_{turn_id}.json` / `session_meta.json` / `_session_tokens.json`),可直接打开查看/修改,便于审计与调试 |
| **mysql** | session 三表 / cron 两表 | 消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)两个后端均落盘 |
- 由 `STORAGE_BACKEND` + `MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE` 配置,`agent/core/db.py` 提供统一连接与幂等建表
- **不双写**:选哪种后端,读写就只走该后端;写入幂等(同一 `(session_id, turn_id)` 先 DELETE 再 INSERT);DB 异常仅打印日志,不阻断主流程
- 分支(`/api/sessions/branch`)与回滚等会话功能均基于存储接口实现,两个后端一致可用
**双后端行为差异(设计内)**
| 场景 | json | mysql |
|------|------|-------|
| 轮次中实时工具记录 | 每次工具结果后**增量写盘** `tool_*.json`,进程崩溃后重启可回退恢复最近记录 | 不增量入库,由轮次结束 `save_tool_calls` 统一落库(无额外磁盘 IO,但崩溃时该轮记录丢失) |
| 服务重启后运行中轮次 | `get_live_tool_calls` 回退读取增量文件,可继续展示 | 内存记录丢失、数据库无该轮记录,实时面板为空 |
| cron 历史读取 | 直接读 `history/cron/` 下文件 | 子进程直写 cron 表,历史可正常读取 |
---
## 🔌 LLM 推理服务
@@ -516,8 +591,10 @@ cloudflared tunnel --url http://localhost:8765
| `canvas-editor.js` | 画板编辑器(画笔/形状/文字/橡皮擦,支持缩放与图片修改) |
| `audio.js` | 语音录制与播放 |
| `i18n.js` | 国际化 |
| `token-bar.js` | Token 用量条 |
| `pending.js` / `pending-overlay.js` | 待确认操作 |
| `token-ring.js` | 上下文 Token 用量环形指示器(含压缩阈值刻度与三类占比) |
| `access-mode.js` | 工作空间访问模式切换(限制访问 / 权限审查 / 完全访问) |
| `think-level.js` | 思考模式档位选择(low / high / xhigh / max / ultra) |
| `pending.js` / `pending-overlay.js` | 人工请求浮窗与决策提交(数据源为 live snapshot 的 `human_requests`) |
| `rollback.js` | 消息回滚 |
| `workspace.js` | 工作空间切换 |
| `dialog.js` | 对话框 |
@@ -525,13 +602,13 @@ cloudflared tunnel --url http://localhost:8765
### CSS 架构(`src/web/css/`)
`variables.css` 定义主题变量,其余按模块拆分:`base / layout / chat / composer / cron / dialog / settings / skills / todo / toolcalls / edit-mode / audio / animations / pending / pending-overlay / responsive`。
`variables.css` 定义主题变量,其余按模块拆分:`base / layout / chat / composer / cron / dialog / settings / skills / todo / toolcalls / edit-mode / canvas-editor / audio / animations / pending / pending-overlay / toast / responsive`。
### 添加自定义工具
1. 在 `src/agent/tools/` 新建 `my_tool.py`,继承 LangChain `BaseTool`,实现 `_run`,填写 `name` / `description` / `args_schema`
2. 在 `tool_config.json` 注册并配置启用状态与参数
3. (可选)在 `tool_policy.py` 设置是否需要人工确认
2. 在 `tool_config.json` 注册并配置启用状态与参数(权限设为 `confirm` 时,工具执行前会自动弹出确认浮窗,同意后执行、拒绝/跳过作为返回值继续)
3. 交互类工具(需要向用户提问)在 `_run` 中调用 `core/human_request.ask_human(meta)` 阻塞等待答案;展示类工具(如进度面板)返回纯文本即可,前端从 live 工具记录按工具名写 adapter 渲染浮窗
4. 重启后端,工具自动绑定到 LLM
### 添加自定义技能
@@ -557,7 +634,7 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force
- **后端日志**:桌面端 `npm start` 会在终端打印 `[FastAPI]` 日志
- **端口占用**:`main.js` 的 `killPortProcess(8765)` 启动前自动清理
- **重置配置**:删除 `src/agent/config/env_config.json` 重触首次向导
- **工具调用轨迹**:`history/tool_calling/{session_id}/turn_{id}.json`
- **工具调用轨迹**:`history/sessions/{session_id}/tool_{turn_id}.json`
---
@@ -571,7 +648,7 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force
| 🌐 **首次启动联网** | 加载 Google Fonts 需联网;离线环境字体选择器有 HTML 硬编码兜底 |
| 🔑 **API Key 安全** | `env_config.json` 含明文凭证,**勿提交到公开仓库**(已在 `.gitignore` 排除 `env_config.json`,仅保留 `env_config_dist.json` 模板) |
| 🎨 **图标** | 当前为 SVG,NSIS 打包后若显示默认图标,需准备 `.ico` 文件替换 `src/web/image/icon.ico` |
| 💾 **历史存储** | 会话历史与工具轨迹落盘在 `history/`,长期使用注意清理 |
| 💾 **历史存储** | 默认会话历史与工具轨迹以 JSON 落盘在 `history/`(可切换 MySQL 后端),长期使用注意清理 |
| ⚡ **GUI 性能** | 同页多操作务必合并为一次 `gui` 调用,否则延迟显著(见「实现细节」) |
---
@@ -580,7 +657,7 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force
<!-- 新增贡献者请按此格式在下方追加一行:- [用户名](GitHub 个人链接):贡献内容 -->
- [minor](https://github.com/minorQAQ):项目话事人,主导整体架构设计与核心实现(LangGraph 编排、19 项工具体系、多 Agent 模式、上下文分级压缩、循环检测、Electron 桌面端等),完成大部分的开发工作。
- [minor](https://github.com/minorQAQ):项目话事人,主导整体架构设计与核心实现(LangGraph 编排、20 项工具体系、多 Agent 模式、上下文分级压缩、循环检测、Electron 桌面端等),完成大部分的开发工作。
---
-2
View File
@@ -20,7 +20,6 @@ from agent.agents.agent_runtime import (
get_agent_runtime,
reload_all_agent_runtimes,
execute_agent,
continue_after_human_action,
main_agent_runtime,
AgentMode,
)
@@ -36,6 +35,5 @@ __all__ = [
"get_all_agent_runtimes",
"get_agent_runtime",
"execute_agent",
"continue_after_human_action",
"AgentMode",
]
+1 -20
View File
@@ -3,7 +3,7 @@
系统定位:
实现从配置文件全动态加载 Agent 实例。所有 Agent 的运行参数(图类型、
模型、工具集、系统提示词、最大迭代次数)均由 agent_config.json 驱动。
同时提供 Web/API 层的入口函数 execute_agent 和 continue_after_human_action。
同时提供 Web/API 层的入口函数 execute_agent。
使用方式:
from agent.agents.agent_runtime import get_main_agent_runtime, reload_all_agent_runtimes, execute_agent
@@ -15,7 +15,6 @@ from typing import Any, Literal
from agent.core.config_manager import load_agent_configs, load_models, AgentConfig
from agent.core.graph import build_agent_graph
from agent.core.runtime import continue_after_human_action as continue_runtime_after_human_action
from agent.core.runtime import execute_agent as execute_runtime_agent
from agent.core.runtime import AgentRuntime
from agent.tools import get_tools
@@ -265,24 +264,6 @@ def execute_agent(
)
def continue_after_human_action(
chat_history: list[dict[str, Any]],
session_id: str,
approval_id: str,
decision: Literal["approve", "reject", "edit"],
instruction: str | None = None,
) -> list[dict[str, Any]]:
"""Web/API 层入口:用户对待确认项决策后继续执行 main_agent。"""
return continue_runtime_after_human_action(
runtime=main_agent_runtime,
chat_history=chat_history,
session_id=session_id,
approval_id=approval_id,
decision=decision,
instruction=instruction,
)
# ---------- 首次导入时从配置构建主 Agent 运行时并缓存引用 ----------
_built = build_all_agent_runtimes()
main_agent_runtime = get_main_agent_runtime()
+3 -2
View File
@@ -3,9 +3,10 @@
包含:
- state / graph: LangGraph 状态与图拓扑
- nodes / routing: 图节点与条件路由
- runtime: 对外执行入口 execute_agent / continue_after_human_action
- runtime: 对外执行入口 execute_agent
- human_request: 人工请求注册表(交互类工具的通用阻塞通道)
- turn_runner: 后台线程轮次执行器
- llm: ChatQwen 多模态网关
- approvals: HITL 待确认管理(含 human_interaction 参数规范化)
- tool_policy: 工具执行安全策略
系统定位:
-259
View File
@@ -1,259 +0,0 @@
"""待确认操作(HITL):内存注册、UI 元数据构建、参数规范化与结果生成。
系统定位:
维护进程内 ``PENDING_TOOL_APPROVALS`` 字典;在图因需确认工具而暂停时
保存上下文并构建前端 UI 元数据;在用户操作后生成 ToolMessage 供图继续执行。
内容分区:
- PENDING 存储与元数据构建(build_approval_meta / build_human_interaction_meta)
- UI 消息辅助(remove_pending_ui_message)
- human_interaction 工具的参数规范化与结果文案(normalize / result)
注意:
当前为内存存储,进程重启后待确认项丢失;生产环境可改为 Redis/DB。
可扩展性:
- 增加 TTL、session 级清理、持久化后端。
- ``build_*_meta`` 可与前端组件协议版本化。
- 新增 interaction_type 时同步扩展 normalize / result 分支。
"""
from __future__ import annotations
import json
import uuid
from typing import Any, Sequence
from langchain_core.messages import BaseMessage
from langchain_core.messages import messages_to_dict
from agent.tools.human_interaction import HumanInteraction
from agent.utils.tool_call_utils import json_safe
# ============================================================
# PENDING 存储
# ============================================================
PENDING_TOOL_APPROVALS: dict[str, dict[str, Any]] = {}
# ============================================================
# UI 元数据构建
# ============================================================
def build_approval_meta(session_id: str, tool_call: dict[str, Any], messages: Sequence[BaseMessage], agent_name: str = "主Agent") -> dict[str, Any]:
"""为需确认的普通工具调用构建 UI 元数据并注册 pending。
输入:
session_id: 会话 ID。
tool_call: normalize 后的工具调用 dict。
messages: 当前图内消息序列(序列化后存入 pending)。
agent_name: 发起该待确认项的 agent 名称(主 Agent 默认 "主Agent")。
输出:
``{"pending_action": {...}}``,含 id、type=tool_call、args、options 等。
"""
approval_id = uuid.uuid4().hex
tool_name = tool_call.get("name") or "unknown_tool"
args = json_safe(tool_call.get("args") or {})
display_call = {"name": tool_name, "args": args}
instruction = json.dumps(display_call, ensure_ascii=False, indent=2)
PENDING_TOOL_APPROVALS[approval_id] = {
"session_id": session_id,
"type": "tool_call",
"tool_call": {"id": tool_call.get("id"), "name": tool_name, "args": args},
"messages": messages_to_dict(list(messages)),
"agent_name": agent_name,
"is_sub_agent": agent_name not in ("", "主Agent", "__main__"),
}
return {
"pending_action": {
"id": approval_id,
"type": "tool_call",
"title": f"待确认执行工具:{tool_name}",
"tool_name": tool_name,
"args": display_call,
"instruction": instruction,
"options": ["approve", "reject"],
"agent_name": agent_name,
"is_sub_agent": agent_name not in ("", "主Agent", "__main__"),
"resolved": False,
}
}
def build_human_interaction_meta(session_id: str, tool_call: dict[str, Any], messages: Sequence[BaseMessage], agent_name: str = "主Agent") -> dict[str, Any]:
"""为 human_interaction 工具构建 UI 元数据并注册 pending。
输入:
session_id: 会话 ID。
tool_call: 含 name=args 的工具调用。
messages: 当前消息序列。
agent_name: 发起该待确认项的 agent 名称(主 Agent 默认 "主Agent")。
输出:
``{"pending_action": {...}}``,type=human_interaction,含 interaction_type 等。
"""
approval_id = uuid.uuid4().hex
args = normalize_human_interaction_args(tool_call.get("args") or {})
interaction_type = args.get("interaction_type") or "information"
default_titles = {
"information": "请补充信息",
"selection": "请选择一个选项",
}
PENDING_TOOL_APPROVALS[approval_id] = {
"session_id": session_id,
"type": "human_interaction",
"tool_call": {"id": tool_call.get("id"), "name": "human_interaction", "args": args},
"messages": messages_to_dict(list(messages)),
"agent_name": agent_name,
"is_sub_agent": agent_name not in ("", "主Agent", "__main__"),
}
return {
"pending_action": {
"id": approval_id,
"type": "human_interaction",
"interaction_type": interaction_type,
"title": args.get("title") or default_titles.get(str(interaction_type), "需要人工处理"),
"prompt": args.get("prompt") or "请处理后继续。",
"tool_name": "human_interaction",
"args": {
"交互类型": interaction_type,
"说明": args.get("prompt") or "请处理后继续。",
"可选项": args.get("options") or [],
},
"instruction": args.get("suggested_response") or "",
"options": args.get("options") or [],
"questions": args.get("questions") or None,
"agent_name": agent_name,
"is_sub_agent": agent_name not in ("", "主Agent", "__main__"),
"resolved": False,
}
}
def remove_pending_ui_message(chat_history: list[dict[str, Any]], approval_id: str) -> None:
"""在 chat_history 中将指定 approval_id 的 pending_action 标记为已解决(原地修改)。"""
for msg in reversed(chat_history):
pending = ((msg.get("meta") or {}).get("pending_action") or {}) if isinstance(msg, dict) else {}
if pending.get("id") == approval_id:
pending["resolved"] = True
msg.setdefault("meta", {})["pending_action"] = pending
return
def get_pending_actions_for_session(session_id: str) -> list[dict[str, Any]]:
"""获取指定会话所有未解决的 pending_action 列表(human_interaction + tool_call + sub_agent)。
从 PENDING_TOOL_APPROVALS 中扫描,返回可供前端浮窗渲染的 pending_action 字典列表。
每项携带 agent_name / is_sub_agent,供前端按 agent 分组弹窗。
"""
actions = []
for approval_id, entry in PENDING_TOOL_APPROVALS.items():
if entry.get("session_id") != session_id:
continue
is_sub = bool(entry.get("is_sub_agent"))
agent_name = entry.get("agent_name") or ("主Agent" if not is_sub else "子Agent")
entry_type = entry.get("type")
if entry_type == "human_interaction":
args = normalize_human_interaction_args((entry.get("tool_call") or {}).get("args") or {})
interaction_type = args.get("interaction_type") or "information"
default_titles = {
"information": "请补充信息",
"selection": "请选择一个选项",
}
actions.append({
"id": approval_id,
"type": "human_interaction",
"interaction_type": interaction_type,
"title": args.get("title") or default_titles.get(str(interaction_type), "需要人工处理"),
"prompt": args.get("prompt") or "请处理后继续。",
"instruction": args.get("suggested_response") or "",
"options": args.get("options") or [],
"questions": args.get("questions") or None,
"tool_name": "human_interaction",
"agent_name": agent_name,
"is_sub_agent": is_sub,
"resolved": False,
})
elif entry_type == "tool_call":
tool_call = entry.get("tool_call") or {}
tool_name = tool_call.get("name") or "unknown_tool"
args = tool_call.get("args") or {}
display_call = {"name": tool_name, "args": args}
actions.append({
"id": approval_id,
"type": "tool_call",
"title": f"待确认执行工具:{tool_name}",
"tool_name": tool_name,
"args": display_call,
"instruction": json.dumps(display_call, ensure_ascii=False, indent=2),
"options": ["approve", "skip"] if is_sub else ["approve", "reject", "skip"],
"agent_name": agent_name,
"is_sub_agent": is_sub,
# 工作空间访问策略:越界审批原因(无则为空串)
"policy_note": entry.get("policy_note") or "",
"resolved": False,
})
return actions
# ============================================================
# human_interaction 工具的参数规范化与结果文案
# ============================================================
def normalize_human_interaction_args(args: dict[str, Any]) -> dict[str, Any]:
"""规范化 human_interaction 工具参数为固定 schema。
输入:
args: 模型传入的原始参数字典。
输出:
含 interaction_type、title、prompt、options、suggested_response、questions 的字典;
interaction_type 限定为 information | selection。
"""
normalized = dict(args or {})
normalized["interaction_type"] = str(normalized.get("interaction_type") or "information")
if normalized["interaction_type"] not in {"information", "selection"}:
normalized["interaction_type"] = "information"
normalized["title"] = str(normalized.get("title") or "需要人工处理")
normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。")
normalized["options"] = HumanInteraction.normalize_options(normalized.get("options"))
normalized["suggested_response"] = str(normalized.get("suggested_response") or "")
# 规范化 questions 字段
questions = normalized.get("questions")
if isinstance(questions, str):
try:
questions = json.loads(questions)
except (json.JSONDecodeError, TypeError):
questions = None
if isinstance(questions, list):
normalized_questions = []
for q in questions:
if isinstance(q, dict):
nq = {
"question": str(q.get("question", "")),
"options": HumanInteraction.normalize_options(q.get("options")),
}
normalized_questions.append(nq)
normalized["questions"] = normalized_questions if normalized_questions else None
else:
normalized["questions"] = None
return normalized
def human_interaction_result(pending: dict[str, Any], decision: str, instruction: str | None) -> str:
"""根据用户决策生成写入 ToolMessage 的文本结果。
输入:
pending: PENDING_TOOL_APPROVALS 中 type=human_interaction 的条目。
decision: approve | reject | edit。
instruction: 用户输入文本(补充信息、选项、修改说明等)。
输出:
供 LangGraph 继续推理的 ToolMessage 字符串内容。
"""
tool_call = pending.get("tool_call") or {}
args = normalize_human_interaction_args(tool_call.get("args") or {})
interaction_type = args.get("interaction_type") or "information"
options = args.get("options") or []
return HumanInteraction.format_result(interaction_type, options, instruction or "")
+165
View File
@@ -0,0 +1,165 @@
"""人工请求注册表:交互类工具与确认钳点的通用阻塞通道。
系统定位:
交互类工具的通用框架服务。工具只需调用 ``ask_human`` 阻塞等待答案并自行
格式化返回值;等待期间请求会随 live snapshot 推送到前端渲染浮窗,
用户通过 ``POST /api/human-action`` 回答后阻塞解除、图自然继续——
同意/拒绝/补充信息都只是普通工具返回值,拒绝不会结束整轮任务。
无人值守(AGENT_HEADLESS,如 cron)时 ``ask_human`` 立即返回默认答案,
避免任务悬死;用户暂停(abort)时阻塞解除并抛 ``ABORTED_BY_USER``,
由 runtime 统一按暂停处理。
可扩展性:
新交互类工具 = 工具内调一次 ``ask_human(meta)``,框架零改动。
"""
from __future__ import annotations
import os
import threading
import time
import uuid
from contextvars import ContextVar
from typing import Any
# ---------- 当前 agent 名称(用于前端浮窗按 agent 分组) ----------
# 主 Agent 使用保留键 "__main__";子 Agent 使用其 agent_name。
# ContextVar + 全局 fallback 双轨:主线程/线程池子进程均可读到正确值;
# HTTP 请求线程(前端轮询)无上下文 → fallback 默认 "__main__"。
_current_agent_name: ContextVar[str] = ContextVar("current_agent_name", default="")
_agent_name_fallback_lock = threading.Lock()
_agent_name_fallback = "__main__"
def set_current_agent_name(agent_name: str) -> None:
"""设置当前线程的 agent 名称(主 Agent 传 "__main__",子 Agent 传其名称)。"""
global _agent_name_fallback
_current_agent_name.set(agent_name or "")
with _agent_name_fallback_lock:
_agent_name_fallback = agent_name or "__main__"
def get_current_agent_name() -> str:
"""获取当前 agent 名称;ContextVar 为空时回退到全局 fallback(默认 "__main__")。"""
name = _current_agent_name.get()
if name:
return name
with _agent_name_fallback_lock:
return _agent_name_fallback or "__main__"
# ---------- 等待中的请求注册表 ----------
# request_id -> {session_id, event, answer, meta, created_at}
_WAITING_REQUESTS: dict[str, dict[str, Any]] = {}
_requests_lock = threading.Lock()
_ABORT_CHECK_INTERVAL = 0.2 # 等待循环中检查 abort 标志的间隔(秒)
_DEFAULT_HEADLESS_ANSWER = "无人值守环境,无法进行人工交互,请基于现有信息自行决策。"
def _notify(session_id: str) -> None:
"""推送一次 live snapshot,使前端浮窗数据源(human_requests)即时更新。"""
try:
from agent.history.tool_call_recorder import _notify_live
_notify_live(session_id)
except Exception:
pass
def ask_human(session_id: str, meta: dict[str, Any], timeout: float | None = None) -> dict[str, Any]:
"""阻塞等待人工应答,返回 ``{"decision": str, "instruction": str}``。
输入:
session_id: 会话 ID。
meta: 浮窗渲染数据(id/type/title/prompt/options/questions/instruction/
agent_name/is_sub_agent/policy_note 等),由调用方按前端协议构造。
timeout: 等待超时秒数;None 表示无限等待。
输出:
用户决策字典。decision 取值与前端一致:approve | reject | edit | skip;
无人值守时立即返回默认 reject;超时返回 decision="timeout"。
异常:
等待期间检测到 abort 标志时抛 RuntimeError("ABORTED_BY_USER"),
由 runtime 统一按手动暂停处理。
"""
# 无人值守(cron/headless):不阻塞,立即返回默认答案
if os.environ.get("AGENT_HEADLESS"):
return {"decision": "reject", "instruction": _DEFAULT_HEADLESS_ANSWER}
request_id = uuid.uuid4().hex
entry: dict[str, Any] = {
"session_id": session_id,
"request_id": request_id,
"event": threading.Event(),
"answer": None,
"meta": dict(meta or {}),
"created_at": time.time(),
}
entry["meta"].setdefault("id", request_id)
with _requests_lock:
_WAITING_REQUESTS[request_id] = entry
_notify(session_id)
try:
deadline = None if timeout is None else time.time() + timeout
while True:
# 每次醒来先检查 abort:暂停优先于答案
try:
from agent.core.runtime import check_abort
if session_id and check_abort(session_id):
raise RuntimeError("ABORTED_BY_USER")
except RuntimeError:
raise
except Exception:
pass
if entry["event"].wait(timeout=_ABORT_CHECK_INTERVAL):
# 醒来后再检查一次 abort:暂停优先于答案,避免与 resolve 的竞态
try:
from agent.core.runtime import check_abort
if session_id and check_abort(session_id):
raise RuntimeError("ABORTED_BY_USER")
except RuntimeError:
raise
except Exception:
pass
break
if deadline is not None and time.time() >= deadline:
return {"decision": "timeout", "instruction": "等待人工应答超时"}
return entry["answer"] or {"decision": "reject", "instruction": "未收到应答"}
finally:
with _requests_lock:
_WAITING_REQUESTS.pop(request_id, None)
_notify(session_id)
def answer_human_request(session_id: str, request_id: str, decision: str, instruction: str = "") -> bool:
"""回答一个等待中的人工请求(由 POST /api/human-action 调用)。
输出:
True 表示找到并已应答;False 表示请求不存在或不属于该会话(已过期)。
"""
with _requests_lock:
entry = _WAITING_REQUESTS.get(request_id)
if entry is None or entry.get("session_id") != session_id:
return False
entry["answer"] = {"decision": decision, "instruction": instruction or ""}
entry["event"].set()
return True
def get_human_requests_for_session(session_id: str) -> list[dict[str, Any]]:
"""获取指定会话所有等待中的人工请求(供 live snapshot 推送给前端渲染浮窗)。"""
with _requests_lock:
return [dict(e["meta"]) for e in _WAITING_REQUESTS.values() if e.get("session_id") == session_id]
def resolve_all_for_session(session_id: str, decision: str = "reject", instruction: str = "") -> int:
"""一次性应答指定会话的所有等待请求(abort/清会话时调用),返回应答数量。"""
with _requests_lock:
entries = [e for e in _WAITING_REQUESTS.values() if e.get("session_id") == session_id]
for e in entries:
e["answer"] = {"decision": decision, "instruction": instruction or ""}
e["event"].set()
return len(entries)
+4 -141
View File
@@ -1,8 +1,8 @@
"""Agent 死循环检测模块。
系统定位:
在 ReAct 循环的 agent 节点和路由节点之间,检测工具调用死循环、
todolist 进度停滞等异常模式,提供分级响应:
在 ReAct 循环的 agent 节点和路由节点之间,检测连续相同工具调用的异常模式,
提供分级响应:
- Level 1: 注入反思提示引导模型自我纠正
- Level 2: 强制终止图执行并向用户报告
@@ -16,10 +16,9 @@ from __future__ import annotations
import hashlib
import json
import threading
from typing import Any, Optional
from typing import Any
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.messages import AIMessage
from agent.utils.tool_call_utils import normalize_tool_call, format_args_summary
@@ -35,53 +34,10 @@ def get_loop_config() -> dict:
except Exception:
env = {}
return {
"todolist_stale_rounds": int(env.get("LOOP_DETECT_TODOLIST_STALE_ROUNDS", 10)),
"repeated_tool_warn": int(env.get("LOOP_DETECT_REPEATED_TOOL_WARN", 3)),
"repeated_tool_end": int(env.get("LOOP_DETECT_REPEATED_TOOL_END", 5)),
}
# ============================================================
# 状态存储(按 session_id,线程安全)
# ============================================================
_lock = threading.Lock()
_session_state: dict[str, dict] = {}
def _get_session_state(session_id: str) -> dict:
"""获取或创建 session 级循环检测状态。"""
if not session_id:
session_id = "_default"
with _lock:
if session_id not in _session_state:
_session_state[session_id] = {
"round_count": 0,
"last_todolist_done_count": -1,
"todolist_stale_rounds": 0,
}
return _session_state[session_id]
def reset_session(session_id: str) -> None:
"""重置指定 session 的循环检测状态(每轮用户输入时调用)。"""
if not session_id:
session_id = "_default"
with _lock:
_session_state[session_id] = {
"round_count": 0,
"last_todolist_done_count": -1,
"todolist_stale_rounds": 0,
}
def clear_session(session_id: str) -> None:
"""清除指定 session 的循环检测状态(会话结束时调用)。"""
if not session_id:
session_id = "_default"
with _lock:
_session_state.pop(session_id, None)
# ============================================================
# 工具调用历史分析
# ============================================================
@@ -181,86 +137,6 @@ def detect_repeated_tool_calls(
return consecutive, last_call.get("name", "unknown"), args_summary
# ============================================================
# Todolist 进度停滞检测
# ============================================================
def _get_current_todolist_done_count(session_id: str) -> int:
"""获取当前 todolist 的已完成步骤数。"""
try:
from agent.tools.todo_list import get_todo_store_data
todo = get_todo_store_data(session_id)
if todo and todo.get("steps"):
return len(todo.get("done_steps", []))
except Exception:
pass
return -1
def check_and_record_todolist_progress(
messages: list,
session_id: str = "",
) -> tuple[bool, int, str]:
"""检查 todolist 进度是否停滞,并更新内部状态。
每轮 call_model 调用时调用一次。随着 round_count 递增,
对比当前 done_count 与上次记录的 done_count,统计停滞轮数。
输入:
messages: 当前消息列表。
session_id: 会话 ID。
输出:
(是否停滞超过阈值, 停滞轮数, todolist 状态摘要)
需要注入反思提示时返回 True。
"""
cfg = get_loop_config()
stale_threshold = max(cfg["todolist_stale_rounds"], 1)
state = _get_session_state(session_id)
state["round_count"] = state.get("round_count", 0) + 1
current_done = _get_current_todolist_done_count(session_id)
# 如果没有 todolist,不检查
if current_done < 0:
state["last_todolist_done_count"] = -1
state["todolist_stale_rounds"] = 0
return False, 0, ""
last_done = state.get("last_todolist_done_count", -1)
if current_done != last_done:
# 有进展,重置停滞计数
state["last_todolist_done_count"] = current_done
state["todolist_stale_rounds"] = 0
return False, 0, ""
else:
# 无进展,递增停滞计数
state["todolist_stale_rounds"] = state.get("todolist_stale_rounds", 0) + 1
stale = state["todolist_stale_rounds"]
# 构建 todolist 摘要
try:
from agent.tools.todo_list import get_todo_store_data
todo = get_todo_store_data(session_id)
steps = todo.get("steps", []) if todo else []
done_set = set(todo.get("done_steps", [])) if todo else set()
next_undone = ""
for i, s in enumerate(steps):
if i not in done_set:
next_undone = s[:50]
break
total = len(steps)
summary = f"总共 {total} 步,当前卡在步骤 {len(done_set)+1}/{total}:{next_undone}"
except Exception:
summary = f"已停滞 {stale} 轮"
if stale >= stale_threshold:
return True, stale, summary
return False, stale, summary
def should_force_end(
messages: list,
session_id: str = "",
@@ -319,19 +195,6 @@ def build_loop_reflection_prompt(
f"3. 如果确实无法推进,向用户说明当前困难并请求帮助"
)
# 检查 2: Todolist 进度停滞
is_stale, stale_rounds, stale_summary = check_and_record_todolist_progress(
messages, session_id
)
stale_threshold = max(cfg["todolist_stale_rounds"], 1)
if is_stale:
parts.append(
f"【进度停滞警告】TodoList 已有 {stale_rounds} 轮没有进度"
+ (f"({stale_summary})。" if stale_summary else "。")
+ f"\n请反思当前卡点,考虑是否需要调整计划或换一种方式继续。"
)
if not parts:
return None
+1 -43
View File
@@ -115,35 +115,6 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
return cleaned
def _get_todolist_status() -> str:
"""获取当前会话的 todolist 状态摘要。
输出:
todolist 状态文本;无 todolist 时返回空字符串。
"""
try:
from agent.history.tool_call_recorder import get_current_session
from agent.tools.todo_list import get_todo_store_data
sid = get_current_session()
if not sid:
return ""
todo = get_todo_store_data(sid)
if not todo or not todo.get("steps"):
return ""
steps = todo["steps"]
done = set(todo.get("done_steps", []))
lines = []
for i, s in enumerate(steps):
status = "[已完成]" if i in done else "[待完成]"
lines.append(f" {i+1}. {status} {s}")
total = len(steps)
done_count = len(done)
header = f"当前TodoList进度:{done_count}/{total} 已完成"
return header + "\n" + "\n".join(lines)
except Exception:
return ""
def _build_reflection_prompt(agent_mode: str, is_first_call: bool, messages: list, session_id: str = "") -> str | None:
"""构建反思提示词,引导 Agent 在下一步执行前进行思考。
@@ -156,20 +127,13 @@ def _build_reflection_prompt(agent_mode: str, is_first_call: bool, messages: lis
输出:
反思提示文本,不需要反思时返回 None。
"""
todo_status = _get_todolist_status()
# 非 plan 模式首次调用,不需要反思
if is_first_call:
return None
# 后续调用:引导模型先思考再行动
# 后续调用:引导模型先思考再行动(TodoList 状态由工具返回值提供,无需额外注入)
parts = [REFLECTION_PROMPT]
if todo_status:
parts.append(todo_status)
else:
parts.append("当前无 TodoList。")
# 循环检测:注入反循环反思提示
loop_warning = build_loop_reflection_prompt(messages, session_id)
if loop_warning:
@@ -526,12 +490,6 @@ def make_call_model_node(
sid = get_current_session()
if sid:
record_tool_call_live(sid, tool_name, tool_args)
if tool_name == "todo_list":
try:
from agent.tools.todo_list import update_todo_store_from_tool_args
update_todo_store_from_tool_args(sid, tool_args)
except Exception:
pass
except Exception:
pass
+8 -71
View File
@@ -1,27 +1,22 @@
"""LangGraph 条件路由:工具循环 vs 结束 vs 待确认暂停。
"""LangGraph 条件路由:工具循环 vs 结束。
系统定位:
连接 ``graph`` 的条件边与 ``runtime.execute_agent`` 的
后处理逻辑,在模型输出 tool_calls 时决定图走向或提取 UI 待确认元数据。
连接 ``graph`` 的条件边,在模型输出 tool_calls 时决定图走向。
人工确认不再由图级暂停承接:confirm 工具 / human_interaction 均为
阻塞式普通工具,在 ``tool_call_utils.invoke_tool_and_build_message``
钳点处阻塞等待用户决策,图自然继续。
可扩展性:
- ``should_continue`` 可扩展为多目标路由(如专用 RAG 节点)。
- ``first_pending_tool_meta`` 可支持多个并行待确认项的优先级队列。
"""
from __future__ import annotations
from typing import Any, Literal, Sequence
from typing import Literal
from langchain_core.messages import BaseMessage
from langgraph.graph import END
from agent.core.approvals import PENDING_TOOL_APPROVALS, build_approval_meta, build_human_interaction_meta
from agent.core.loop_detector import should_force_end as _loop_should_force_end
from agent.core.state import AgentState
from agent.core.tool_policy import classify_tool_execution
from agent.core.workspace_policy import check_violation as _policy_check_violation
from agent.core.workspace_policy import decision as _policy_decision
from agent.utils.tool_call_utils import normalize_tool_call
from agent.core.loop_detector import should_force_end as _loop_should_force_end
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
@@ -31,12 +26,11 @@ def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
state: 含 messages 的 AgentState。
输出:
``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(含需确认工具场景、
``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(无待执行工具调用、
循环检测强制终止)。
系统定位:
``graph`` 中 agent 节点的 conditional_edges 路由函数。
若任一 tool_call 策略为 confirm,则直接 END,由 runtime 展示确认 UI。
若检测到死循环(连续相同工具调用超阈值),则强制 END。
可扩展性:
@@ -55,62 +49,5 @@ def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
force_end, loop_tool, loop_args = _loop_should_force_end(messages, _sid)
if force_end:
return END
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
return END
# 权限审查模式:越界操作暂停图,交由前端人工审批
if _policy_decision(tool_call.get("name"), tool_call.get("args") or {}) == "approve":
return END
return "tools"
return END
def first_pending_tool_meta(messages: Sequence[BaseMessage], session_id: str) -> dict[str, Any] | None:
"""从图执行结果中提取第一个需 UI 处理的待确认项元数据。
输入:
messages: 图 invoke 返回的完整消息列表。
session_id: 当前会话 ID,写入 PENDING_TOOL_APPROVALS。
输出:
含 ``pending_action`` 的 meta 字典;无待确认项时返回 None。
系统定位:
``runtime.execute_agent`` 在图结束后调用,决定是否追加确认型 assistant 消息。
可扩展性:
可返回列表以支持批量确认;可按工具类型定制 meta 结构。
"""
if not messages:
return None
last_msg = messages[-1]
if not hasattr(last_msg, "tool_calls") or not last_msg.tool_calls:
return None
# 当前 agent 名称(主 Agent 显示为 "主Agent",子 Agent 为其名称)
try:
from agent.tools.todo_list import get_current_agent_name
_ag = get_current_agent_name()
agent_name = "主Agent" if _ag in ("", "__main__") else _ag
except Exception:
agent_name = "主Agent"
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
if tool_call.get("name") == "human_interaction":
return build_human_interaction_meta(session_id, tool_call, messages, agent_name=agent_name)
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
return build_approval_meta(session_id, tool_call, messages, agent_name=agent_name)
# 权限审查模式:越界操作注册审批(复用 tool_call 审批卡片),并携带越界原因
if _policy_decision(tool_call.get("name"), tool_call.get("args") or {}) == "approve":
meta = build_approval_meta(session_id, tool_call, messages, agent_name=agent_name)
approval_id = (meta.get("pending_action") or {}).get("id")
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
reason = (_policy_check_violation(tool_call.get("name"), tool_call.get("args") or {})
or "检测到超出工作空间范围的操作")
PENDING_TOOL_APPROVALS[approval_id]["policy_note"] = reason
pa = meta["pending_action"]
pa["title"] = "待审批:检测到超出工作空间范围的操作"
pa["policy_note"] = reason
return meta
return None
+17 -625
View File
@@ -1,37 +1,33 @@
"""Agent 运行时:对话执行、历史持久化与人工确认续跑。
"""Agent 运行时:对话执行与历史持久化。
系统定位:
Web 层(即 ``web.ui_session``)与 LangGraph 之间的编排层。
``agents/*`` 组装 ``AgentRuntime`` 实例,本模块提供统一执行 API。
主要流程:
execute_agent: 用户消息 -> summarizer 压缩 -> graph.invoke -> 响应/待确认
continue_after_human_action: 用户确认/拒绝 -> 工具执行或图续跑
execute_agent: 用户消息 -> summarizer 压缩 -> graph.invoke -> 响应
人机交互为阻塞式普通工具(见 ``agent.core.human_request``):图内工具调用
在线程中阻塞等待前端浮窗应答,同意/拒绝/补充信息都只是普通工具返回值,
无需图暂停/续跑机制(不再有 continue_after_human_action)。
可扩展性:
- ``AgentRuntime`` 可增加 checkpointer、tracing、多模态配置。
"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass
from typing import Any, Literal
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langgraph.errors import GraphRecursionError
from langchain_core.messages import messages_from_dict
from agent.core.approvals import PENDING_TOOL_APPROVALS, remove_pending_ui_message, human_interaction_result
from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, save_aborted_turn, set_current_turn, set_current_session
from agent.memory.system_prompt import get_plan_system_prompt, get_cron_system_prompt
from agent.core.loop_detector import reset_session as reset_loop_session, should_force_end as loop_should_force_end, build_force_end_message, clear_session as clear_loop_session
from agent.core.routing import first_pending_tool_meta
from agent.core.tool_policy import classify_tool_execution
from agent.tools.agent_call import SubAgentPendingError
from agent.core.loop_detector import should_force_end as loop_should_force_end, build_force_end_message
from agent.utils.agent_utils import assistant_text, get_history
from agent.utils.tool_call_utils import normalize_tool_call, invoke_tool_and_build_message
from web.ui_session import rewrite_user_message_files_to_session_dir, session_dir, sync_turn_from_chat_history
from web.ui_session import rewrite_user_message_files_to_session_dir, sync_turn_from_chat_history
AgentMode = Literal["agent", "plan", "cron"]
@@ -104,28 +100,6 @@ def _inject_tool_history_context(session_id: str, messages: list) -> None:
pass
def _rollback_chat_history_to_last_user(chat_history: list[dict[str, Any]], session_id: str) -> list[dict[str, Any]]:
"""将 chat_history 回滚到上一条用户消息之前,并同步 turn 文件。"""
rolled_back = list(chat_history)
while rolled_back and rolled_back[-1].get("role") != "user":
rolled_back.pop()
if rolled_back:
rolled_back.pop()
if session_id:
sync_turn_from_chat_history(session_id, rolled_back, "")
return rolled_back
def _ensure_todo_cleared(session_id: str) -> None:
"""安全清理指定会话的 todo_list 缓存。"""
if not session_id:
return
try:
from agent.tools.todo_list import clear_todo_store_session
clear_todo_store_session(session_id)
except Exception:
pass
# ---------- Abort 机制 ----------
_abort_flags: dict[str, threading.Event] = {}
_abort_lock = threading.Lock()
@@ -174,146 +148,6 @@ def _invoke_with_recursion_limit(
except GraphRecursionError:
print(f"[OOM] Agent '{runtime.name}' 达到最大迭代次数 {runtime.max_iterations},输出 OOM。)")
return {"messages": [AIMessage(content=_OOM_MESSAGE)], "_oom": True}
except SubAgentPendingError as e:
# 子 Agent 触发 HITL:冒泡到主图,返回主图消息快照 + sub_pending 元数据
return {
"messages": e.main_messages or [],
"_sub_pending": e.pending_meta,
"_main_tool_call_id": e.main_tool_call_id,
}
def save_messages_to_history(session_id: str, user_msg: HumanMessage, assistant_msg: AIMessage) -> None:
"""将本轮用户与助手消息写入 JSON 聊天历史(turn 文件)。
输入:
session_id: 会话 ID;空则跳过。
user_msg: 用户 HumanMessage;含 synthetic 标记时不写入。
assistant_msg: 助手 AIMessage。
输出:
无。
系统定位:
execute_agent 正常回复结束后持久化对话。
可扩展性:
可改为异步写入、双写向量库、按 agent_name 分表。
"""
if not session_id:
return
history = get_history(session_id)
if not user_msg.additional_kwargs.get("synthetic"):
history.add_message(user_msg)
history.add_message(assistant_msg)
def _message_content_equals(left: Any, right: Any) -> bool:
"""宽松比较消息 content,兼容纯文本与多模态列表。"""
if left == right:
return True
return assistant_text(left) == assistant_text(right)
def _pending_action_id(meta: dict[str, Any] | None) -> str | None:
"""从 assistant meta 中提取 pending_action.id。"""
pending_action = (meta or {}).get("pending_action") or {}
return pending_action.get("id")
def _remember_pending_user_content(pending_meta: dict[str, Any], user_content: Any) -> None:
"""将本轮用户输入缓存到 PENDING_TOOL_APPROVALS,供确认续跑时对齐历史。"""
approval_id = _pending_action_id(pending_meta)
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
PENDING_TOOL_APPROVALS[approval_id]["user_content"] = user_content
def _persist_messages_from_pending_turn(
session_id: str,
messages: list[Any],
pending: dict[str, Any],
) -> None:
if not session_id or not messages:
return
# 找到本轮用户消息
user_content = pending.get("user_content")
start_index = 0
if user_content is not None:
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
if _message_content_equals(msg.content, user_content):
start_index = idx
break
# 只提取:
# - 用户消息(跳过 synthetic。)
# - 最终一条不包含 tool_calls 的助手消息
user_msg = None
final_assistant = None
for msg in messages[start_index:]:
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
user_msg = msg
if isinstance(msg, AIMessage) and not msg.tool_calls:
final_assistant = msg
history = get_history(session_id)
if user_msg:
history.add_message(user_msg)
if final_assistant:
history.add_message(final_assistant)
def _invoke_after_manual_tool_result(
runtime: AgentRuntime,
messages: list[Any],
session_id: str,
*,
max_steps: int = 8,
agent_mode: str = "agent",
) -> tuple[list[Any], dict[str, Any] | None]:
"""人工确认后继续推进图,并在遇到新的待确认项时暂停。"""
current_messages = messages
for _ in range(max_steps):
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": current_messages, "agent_mode": agent_mode}, {"configurable": {"session_id": session_id}})
all_messages = result["messages"]
# 循环检测 Level 2:检查是否因死循环被强制终止
force_end, loop_tool, loop_args = loop_should_force_end(all_messages, session_id)
if force_end:
# 返回特殊标记:pending_meta 中携带 _loop_force_end 信息
return all_messages, {"_loop_force_end": True, "tool_name": loop_tool, "args_summary": loop_args or ""}
pending_meta = first_pending_tool_meta(all_messages, session_id)
if pending_meta:
return all_messages, pending_meta
last_msg = all_messages[-1] if all_messages else None
if not isinstance(last_msg, AIMessage) or not last_msg.tool_calls:
return all_messages, None
next_messages = [*all_messages]
progressed = False
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
tool_name = tool_call.get("name")
tool_args = tool_call.get("args") or {}
tool_call_id = tool_call.get("id") or f"manual_{session_id}_{len(next_messages)}"
if classify_tool_execution(tool_name, tool_args) != "direct":
continue
tool = runtime.tools_by_name.get(tool_name or "")
if not tool:
continue
tool_content, tool_artifact = invoke_tool_and_build_message(tool, tool_name, tool_args, tool_call_id)
msg_kwargs = {"content": tool_content, "tool_call_id": tool_call_id, "name": tool_name or "unknown_tool"}
if tool_artifact:
msg_kwargs["artifact"] = tool_artifact
next_messages.append(ToolMessage(**msg_kwargs))
progressed = True
if not progressed:
return all_messages, None
current_messages = next_messages
return current_messages, None
def _finalize_turn(
@@ -325,7 +159,7 @@ def _finalize_turn(
turn_id: str,
agent_mode: str = "agent",
) -> list[dict[str, Any]]:
"""处理图执行完成后的公共逻辑:检查 pending、保存 turn、构建 assistant 消息。"""
"""处理图执行完成后的公共逻辑:检查循环强制终止、保存 turn、构建 assistant 消息。"""
# 循环检测 Level 2
loop_force_end, loop_tool_name, loop_args_summary = loop_should_force_end(all_messages, session_id)
if loop_force_end:
@@ -335,26 +169,10 @@ def _finalize_turn(
loop_msg = build_force_end_message(loop_tool_name, actual_count, loop_args_summary)
chat_history.append({"role": "assistant", "content": loop_msg, "meta": {"turn_id": turn_id}})
if session_id:
clear_loop_session(session_id)
_ensure_todo_cleared(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# 检查待确认项
pending_meta = first_pending_tool_meta(all_messages, session_id)
if pending_meta:
# 不调用 end_live_turn:human_interaction 和其他工具一样走 live 记录通道,
# 前端持续轮询展示 running 状态,浮窗只是它的"前端 UI"
_remember_pending_user_content(pending_meta, user_content)
approval_id = _pending_action_id(pending_meta)
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
PENDING_TOOL_APPROVALS[approval_id]["agent_mode"] = agent_mode
PENDING_TOOL_APPROVALS[approval_id]["turn_id"] = turn_id
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# 无待确认项:正常结束
# 正常结束
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
@@ -367,9 +185,6 @@ def _finalize_turn(
# 持久化
if session_id and turn_id:
save_turn(session_id, turn_id, {"role": "user", "content": user_content}, all_messages)
_persist_messages_from_pending_turn(session_id, all_messages, {"user_content": user_content})
_ensure_todo_cleared(session_id)
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
@@ -402,6 +217,7 @@ def _classify_error(e: Exception) -> str:
# 通用兜底
return f"执行过程中出现错误,请稍后重试。如果问题持续,请联系开发者。\n错误详情: {msg[:200]}"
def execute_agent(
runtime: AgentRuntime,
chat_history: list[dict[str, Any]],
@@ -417,10 +233,11 @@ def execute_agent(
agent_mode: agent(默认)| plan(先规划再执行)。
输出:
更新后的 chat_history;可能追加 assistant 消息、pending_action。
更新后的 chat_history;可能追加 assistant 消息。
系统定位:
对外主入口,由 Web/API 层在每轮用户发送后调用。
对外主入口,由 Web/API 层(turn_runner)在每轮用户发送后调用。
人机交互在图上线程内阻塞等待,无待确认返回路径。
可扩展性:
可返回 generator 支持流式;可注入 middleware 做鉴权、限流。
@@ -473,10 +290,8 @@ def execute_agent(
turn_id = start_live_turn(session_id)
_set_cur_sess(session_id)
clear_abort_flag(session_id)
# 重置循环检测状态(每轮用户输入后重新开始计数)
reset_loop_session(session_id)
# 主 Agent 上下文:TodoList 按 agent 隔离时,主 Agent 用保留键 "__main__"
from agent.tools.todo_list import set_current_agent_name
# 主 Agent 上下文
from agent.core.human_request import set_current_agent_name
set_current_agent_name("__main__")
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": messages, "agent_mode": agent_mode}, graph_config)
set_current_turn("")
@@ -487,28 +302,11 @@ def execute_agent(
clear_abort_flag(session_id)
save_aborted_turn(session_id, turn_id)
end_live_turn(session_id)
_ensure_todo_cleared(session_id)
chat_history.append({"role": "assistant", "content": "已被用户手动暂停", "meta": {"paused": True}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# ===== 子 Agent HITL 冒泡:主图在 call_subagent 处暂停 =====
sub_pending_meta = result.get("_sub_pending")
if sub_pending_meta:
sub_approval_id = _pending_action_id(sub_pending_meta)
if sub_approval_id and sub_approval_id in PENDING_TOOL_APPROVALS:
entry = PENDING_TOOL_APPROVALS[sub_approval_id]
entry["main_messages"] = list(result.get("messages") or [])
entry["main_tool_call_id"] = result.get("_main_tool_call_id") or f"manual_{sub_approval_id}"
entry["agent_mode"] = agent_mode
entry["turn_id"] = turn_id
entry["user_content"] = user_content
# 不调用 end_live_turn:turn 仍 live,前端轮询展示 running + 浮窗
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
all_messages = result["messages"]
chat_history = _finalize_turn(runtime, all_messages, chat_history, session_id, user_content, turn_id, agent_mode)
return chat_history
@@ -520,7 +318,6 @@ def execute_agent(
clear_abort_flag(session_id)
save_aborted_turn(session_id, turn_id)
end_live_turn(session_id)
_ensure_todo_cleared(session_id)
chat_history.append({"role": "assistant", "content": "已被用户手动暂停", "meta": {"paused": True}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
@@ -546,408 +343,3 @@ def execute_agent(
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _handle_loop_force_end_in_pending(pending_meta: dict[str, Any], chat_history: list[dict[str, Any]], session_id: str) -> list[dict[str, Any]] | None:
"""若 pending_meta 包含 _loop_force_end,写入终止消息并返回 chat_history;否则返回 None。"""
if not pending_meta.get("_loop_force_end"):
return None
loop_msg = build_force_end_message(
pending_meta.get("tool_name", ""), 0,
pending_meta.get("args_summary", ""),
)
chat_history.append({"role": "assistant", "content": loop_msg, "meta": {}})
if session_id:
clear_loop_session(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _finalize_new_pending_after_human_action(
pending_meta: dict[str, Any],
pending: dict[str, Any],
chat_history: list[dict[str, Any]],
session_id: str,
agent_mode: str,
) -> list[dict[str, Any]]:
"""记录新的待确认项并返回 chat_history。"""
_remember_pending_user_content(pending_meta, pending.get("user_content"))
approval_id2 = _pending_action_id(pending_meta)
if approval_id2 and approval_id2 in PENDING_TOOL_APPROVALS:
PENDING_TOOL_APPROVALS[approval_id2]["agent_mode"] = agent_mode
# 传递原始 turn_id 给嵌套的人机交互,确保所有记录写入同一个文件
original_turn_id = pending.get("turn_id", "")
if original_turn_id:
PENDING_TOOL_APPROVALS[approval_id2]["turn_id"] = original_turn_id
# 不追加空消息 — pending_action 由 API 层单独返回
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _resume_graph_after_tool_message(
runtime: AgentRuntime,
prior_messages: list[Any],
session_id: str,
agent_mode: str,
pending: dict[str, Any],
chat_history: list[dict[str, Any]],
turn_id: str,
) -> list[dict[str, Any]]:
"""用已追加好 ToolMessage 的 prior_messages 续跑图,统一处理 pending/结束/持久化。
用于主 Agent skip 续跑、子 Agent 完成后恢复主图。pending 提供 user_content/turn_id。
"""
try:
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id, agent_mode=agent_mode)
if pending_meta:
end_live_turn(session_id)
result = _handle_loop_force_end_in_pending(pending_meta, chat_history, session_id)
if result is not None:
return result
return _finalize_new_pending_after_human_action(pending_meta, pending, chat_history, session_id, agent_mode)
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
chat_history.append({"role": "assistant", "content": content, "meta": {"turn_id": turn_id}})
_persist_messages_from_pending_turn(session_id, all_messages, pending)
if session_id and turn_id:
save_turn(session_id, turn_id, {"role": "user", "content": pending.get("user_content") or ""}, all_messages)
_ensure_todo_cleared(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
except Exception:
import traceback
traceback.print_exc()
end_live_turn(session_id)
content = "续跑图执行时遇到问题。"
chat_history.append({"role": "assistant", "content": content, "meta": {}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _extract_sub_final_text(all_messages: list[Any]) -> str:
"""从子 Agent 图消息中提取最后一条非工具调用的助手消息文本。"""
for msg in reversed(all_messages or []):
if isinstance(msg, AIMessage) and not getattr(msg, "tool_calls", None):
content = msg.content
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
p.get("text", "") for p in content
if isinstance(p, dict) and p.get("type") == "text"
)
return "子 Agent 已完成任务但未返回文字结果。"
def _invoke_sub_graph(
sub_runtime: AgentRuntime,
sub_messages: list[Any],
session_id: str,
agent_name: str,
main_messages: list[Any],
main_tool_call_id: str,
agent_mode: str,
user_content: Any,
turn_id: str,
) -> tuple[list[Any], dict[str, Any] | None]:
"""恢复子 Agent 图执行(复用 _invoke_after_manual_tool_result)。
若子图又产生 pending,已在 PENDING_TOOL_APPROVALS 中标记 is_sub_agent 并附加
sub_runtime/sub_messages/main_messages/main_tool_call_id,供下一轮确认恢复。
"""
from agent.tools.todo_list import set_current_agent_name
set_current_agent_name(agent_name)
# 子 Agent 续跑同样需抑制 record_*_live,避免其思考/工具调用
# 混入主 Agent 的实时列表(与 agent_call.CallSubAgentTool._run 保持一致)。
from agent.history.tool_call_recorder import sub_agent_context
try:
with sub_agent_context():
sub_all_messages, sub_pending_meta = _invoke_after_manual_tool_result(
sub_runtime, sub_messages, session_id, agent_mode=agent_mode
)
finally:
set_current_agent_name("__main__")
if sub_pending_meta and not sub_pending_meta.get("_loop_force_end"):
approval_id = _pending_action_id(sub_pending_meta)
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
entry = PENDING_TOOL_APPROVALS[approval_id]
entry["is_sub_agent"] = True
entry["agent_name"] = agent_name
entry["sub_runtime"] = sub_runtime
entry["sub_messages"] = list(sub_all_messages) # 更新为最新子图消息
entry["main_messages"] = main_messages
entry["main_tool_call_id"] = main_tool_call_id
entry["agent_mode"] = agent_mode
entry["turn_id"] = turn_id
entry["user_content"] = user_content
return sub_all_messages, sub_pending_meta
def _resume_sub_agent(
runtime: AgentRuntime,
pending: dict[str, Any],
chat_history: list[dict[str, Any]],
session_id: str,
approval_id: str,
decision: str,
instruction: str | None,
agent_mode: str,
) -> list[dict[str, Any]]:
"""子 Agent HITL 确认/跳过后恢复:先续子图,子图完成后再恢复主图。"""
sub_runtime = pending.get("sub_runtime")
sub_messages = list(pending.get("sub_messages") or [])
main_messages = list(pending.get("main_messages") or [])
main_tool_call_id = pending.get("main_tool_call_id") or f"manual_{approval_id}"
agent_name = pending.get("agent_name") or "子Agent"
original_turn_id = pending.get("turn_id", "")
sub_type = pending.get("type")
sub_tool_call = dict(pending.get("tool_call") or {})
sub_tool_name = sub_tool_call.get("name") or "unknown_tool"
sub_call_id = sub_tool_call.get("id") or f"sub_{approval_id}"
# 1) 构造子图续跑的 ToolMessage
if decision == "skip" and sub_type != "human_interaction":
sub_messages.append(ToolMessage(
content=f"用户已跳过此工具调用({sub_tool_name}),未执行。",
tool_call_id=sub_call_id, name=sub_tool_name,
))
elif sub_type == "human_interaction":
sub_messages.append(ToolMessage(
content=human_interaction_result(pending, "approve", instruction),
tool_call_id=sub_call_id, name="human_interaction",
))
else:
sub_tool = sub_runtime.tools_by_name.get(sub_tool_name) if sub_runtime else None
if sub_tool:
tc, ta = invoke_tool_and_build_message(sub_tool, sub_tool_name, sub_tool_call.get("args") or {}, sub_call_id)
msg_kwargs = {"content": tc, "tool_call_id": sub_call_id, "name": sub_tool_name}
if ta:
msg_kwargs["artifact"] = ta
sub_messages.append(ToolMessage(**msg_kwargs))
else:
sub_messages.append(ToolMessage(
content=f"工具 {sub_tool_name} 未找到", tool_call_id=sub_call_id, name=sub_tool_name,
))
# 2) 恢复子图
if session_id:
set_current_session(session_id)
if original_turn_id:
from agent.history.tool_call_recorder import set_current_turn as _set_tc_turn
_set_tc_turn(original_turn_id)
sub_all_messages, sub_pending_meta = _invoke_sub_graph(
sub_runtime, sub_messages, session_id, agent_name,
main_messages, main_tool_call_id, agent_mode,
pending.get("user_content"), original_turn_id,
)
if sub_pending_meta:
# 子图又产生 pending(嵌套/连续 HITL)— _invoke_sub_graph 已注册新 pending(含 main 上下文)
result = _handle_loop_force_end_in_pending(sub_pending_meta, chat_history, session_id)
if result is not None:
return result
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# 3) 子图完成:提取最终文本,恢复主图
sub_final_text = _extract_sub_final_text(sub_all_messages)
main_messages.append(ToolMessage(content=sub_final_text, tool_call_id=main_tool_call_id, name="call_subagent"))
from agent.tools.todo_list import set_current_agent_name
set_current_agent_name("__main__")
if session_id:
turn_id = start_live_turn(session_id)
else:
turn_id = original_turn_id or ""
main_pending = {"user_content": pending.get("user_content"), "turn_id": original_turn_id}
return _resume_graph_after_tool_message(runtime, main_messages, session_id, agent_mode, main_pending, chat_history, original_turn_id or turn_id)
def continue_after_human_action(
runtime: AgentRuntime,
chat_history: list[dict[str, Any]],
session_id: str,
approval_id: str,
decision: Literal["approve", "reject", "edit", "skip"],
instruction: str | None = None,
) -> list[dict[str, Any]]:
"""用户对待确认项做出决策后,继续执行或结束该轮交互。
输入:
runtime: Agent 运行时。
chat_history: 当前聊天历史。
session_id: 会话 ID。
approval_id: pending_action.id。
decision: approve | reject | edit | skip。
- approve: 执行工具/确认续跑;skip: 不执行工具但继续图(ToolMessage 标记跳过)。
- reject: 与手动暂停一致——暂停当前 Agent 运行、保留之前的运行记录、
前端追加"已被用户手动暂停"消息(对三种审批统一生效:权限不足 /
workspace 越界 / 人机交互)。
- edit: human_interaction selection 提交文本。
instruction: selection 时为用户输入文本。
输出:
更新后的 chat_history。
系统定位:
UI 确认框回调入口;按 pending.type 与 is_sub_agent 分支:
human_interaction / tool_call / sub_agent。
可扩展性:
可增加 timeout 自动 reject;支持批量 approve 多个 tool_call。
"""
chat_history = chat_history or []
pending = PENDING_TOOL_APPROVALS.pop(approval_id, None)
if not pending:
chat_history.append({"role": "assistant", "content": "该待确认项已处理或已过期。", "meta": {}})
return chat_history
remove_pending_ui_message(chat_history, approval_id)
agent_mode = pending.get("agent_mode", "agent")
# ===== 拒绝:统一三种审批(权限不足 / workspace 越界 / 人机交互)=====
# 与手动暂停(/api/chat/abort)一致:暂停当前 Agent 运行、保留之前的运行记录,
# 前端收到"已被用户手动暂停"消息。不恢复图执行。
if decision == "reject":
last_msg = chat_history[-1] if chat_history else None
if not (last_msg and last_msg.get("role") == "assistant" and last_msg.get("content") == "已被用户手动暂停"):
chat_history.append({"role": "assistant", "content": "已被用户手动暂停", "meta": {"paused": True}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# ===== 子 Agent HITL 恢复(approve / skip,无 reject)=====
if pending.get("is_sub_agent"):
return _resume_sub_agent(runtime, pending, chat_history, session_id, approval_id, decision, instruction, agent_mode)
# ===== 跳过:仅 tool_call 类型支持,human_interaction 不支持跳过 =====
if decision == "skip" and pending.get("type") != "human_interaction":
tool_call = dict(pending.get("tool_call") or {})
tool_name = tool_call.get("name") or "unknown_tool"
call_id = tool_call.get("id") or f"manual_{approval_id}"
skip_content = f"用户已跳过此工具调用({tool_name}),未执行。"
prior_messages = messages_from_dict(pending.get("messages") or [])
prior_messages.append(ToolMessage(content=skip_content, tool_call_id=call_id, name=tool_name))
original_turn_id = pending.get("turn_id", "")
if session_id:
set_current_session(session_id)
if original_turn_id:
from agent.history.tool_call_recorder import set_current_turn as _set_tc_turn
_set_tc_turn(original_turn_id)
turn_id = start_live_turn(session_id)
else:
turn_id = original_turn_id or ""
from agent.tools.todo_list import set_current_agent_name
set_current_agent_name("__main__")
return _resume_graph_after_tool_message(runtime, prior_messages, session_id, agent_mode, pending, chat_history, turn_id)
if pending.get("type") == "human_interaction":
original_turn_id = pending.get("turn_id", "")
if session_id and original_turn_id:
set_current_session(session_id)
# 恢复原始 turn_id 到上下文,确保后续 live 记录写入正确的文件
from agent.history.tool_call_recorder import record_tool_result_live, set_current_turn as _set_tc_turn
_set_tc_turn(original_turn_id)
tool_content = human_interaction_result(pending, decision, instruction)
record_tool_result_live(session_id, "human_interaction", tool_content)
else:
tool_content = human_interaction_result(pending, decision, instruction) if session_id else ""
try:
tool_call = dict(pending.get("tool_call") or {})
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"human_{approval_id}"
prior_messages.append(ToolMessage(content=tool_content, tool_call_id=call_id, name="human_interaction"))
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id, agent_mode=agent_mode)
if pending_meta:
# 新一轮 human_interaction:不清理 live 记录,前端继续轮询展示
result = _handle_loop_force_end_in_pending(pending_meta, chat_history, session_id)
if result is not None:
return result
return _finalize_new_pending_after_human_action(pending_meta, pending, chat_history, session_id, agent_mode)
# 正常结束
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
chat_history.append({"role": "assistant", "content": content, "meta": {"turn_id": original_turn_id or f"human_{approval_id}"}})
_persist_messages_from_pending_turn(session_id, all_messages, pending)
if session_id and original_turn_id:
save_turn(session_id, original_turn_id, {"role": "user", "content": pending.get("user_content") or ""}, all_messages)
_ensure_todo_cleared(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
except Exception:
import traceback
traceback.print_exc()
end_live_turn(session_id)
content = "处理人工交互结果时遇到问题。"
chat_history.append({"role": "assistant", "content": content, "meta": {}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
tool_call = dict(pending.get("tool_call") or {})
tool_name = tool_call.get("name")
tool_args = tool_call.get("args") or {}
tool = runtime.tools_by_name.get(tool_name)
if not tool:
chat_history.append({"role": "assistant", "content": f"找不到工具:{tool_name}", "meta": {}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
if session_id:
set_current_session(session_id)
turn_id = start_live_turn(session_id)
else:
turn_id = ""
try:
tool_content, tool_artifact = invoke_tool_and_build_message(tool, tool_name, tool_args, tool_call.get("id", ""))
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"manual_{approval_id}"
msg_kwargs = {"content": tool_content, "tool_call_id": call_id, "name": tool_name}
if tool_artifact:
msg_kwargs["artifact"] = tool_artifact
prior_messages.append(ToolMessage(**msg_kwargs))
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id, agent_mode=agent_mode)
if pending_meta:
end_live_turn(session_id)
result = _handle_loop_force_end_in_pending(pending_meta, chat_history, session_id)
if result is not None:
return result
return _finalize_new_pending_after_human_action(pending_meta, pending, chat_history, session_id, agent_mode)
# 正常结束
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
chat_history.append({"role": "assistant", "content": content, "meta": {"turn_id": turn_id}})
_persist_messages_from_pending_turn(session_id, all_messages, pending)
if session_id and turn_id:
save_turn(session_id, turn_id, {"role": "user", "content": pending.get("user_content") or ""}, all_messages)
_ensure_todo_cleared(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
except Exception:
import traceback
traceback.print_exc()
end_live_turn(session_id)
content = "执行确认后的工具调用时遇到问题。"
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"manual_{approval_id}"
prior_messages.extend([
ToolMessage(content=content, tool_call_id=call_id, name=tool_name or "unknown_tool"),
AIMessage(content=content),
])
chat_history.append({"role": "assistant", "content": content, "meta": {}})
_persist_messages_from_pending_turn(session_id, prior_messages, pending)
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
File diff suppressed because it is too large. Load diff
+90
View File
@@ -0,0 +1,90 @@
"""轮次执行器:后台线程驱动 execute_agent,解耦 HTTP 请求与图执行生命周期。
系统定位:
Web 层将每次对话轮次放入后台线程执行,HTTP 请求通过 ``start_turn`` /
``wait_turn`` 挂载到同一轮次。断连/关标签页不影响执行——轮次照常跑完并
落盘,重连后可见;同一会话的重复请求共享同一轮,避免并发竞态。
轮次内的人工交互由 ``human_request.ask_human`` 在图上线程中阻塞等待,
前端通过 live snapshot 渲染浮窗,答案由 ``POST /api/human-action`` 应答。
可扩展性:
可增加轮次超时、自动清理、多 runtime 支持。
"""
from __future__ import annotations
import threading
from typing import Any
_turns: dict[str, dict[str, Any]] = {}
_turns_lock = threading.Lock()
def start_turn(session_id: str, chat_history: list[dict[str, Any]], agent_mode: str = "agent") -> str:
"""为会话启动(或复用)一轮后台执行。
输入:
session_id: 会话 ID。
chat_history: 以末条 user 结尾的聊天历史(与 execute_agent 入参一致)。
agent_mode: agent | plan。
输出:
"running" | "done"——该会话是否已有轮次在运行。
"""
with _turns_lock:
existing = _turns.get(session_id)
if existing and not existing["done"].is_set():
return "running"
handle: dict[str, Any] = {
"session_id": session_id,
"done": threading.Event(),
"result": None,
}
_turns[session_id] = handle
def _worker():
try:
from agent.agents.agent_runtime import execute_agent
new_hist = execute_agent(list(chat_history), session_id=session_id, agent_mode=agent_mode)
handle["result"] = {"chat_history": new_hist}
except Exception as e:
import traceback
traceback.print_exc()
handle["result"] = {"error": str(e)}
finally:
handle["done"].set()
thread = threading.Thread(target=_worker, name=f"turn-{session_id}", daemon=True)
handle["thread"] = thread
thread.start()
return "running"
def wait_turn(session_id: str, timeout: float | None = None) -> dict[str, Any] | None:
"""等待轮次结束并返回 execute_agent 的结果 dict。
输出:
{"chat_history": list} | {"error": str};无运行轮次或等待超时返回 None。
timeout=None 表示无限等待(人工交互可能持续较长时间)。
"""
with _turns_lock:
handle = _turns.get(session_id)
if not handle:
return None
if not handle["done"].wait(timeout=timeout):
return None
return handle["result"]
def get_turn_status(session_id: str) -> str:
"""查询会话当前是否有轮次在运行。"""
with _turns_lock:
handle = _turns.get(session_id)
return "running" if handle and not handle["done"].is_set() else "done"
def drop_turn(session_id: str) -> None:
"""丢弃会话的轮次句柄(会话删除时调用;运行中的轮次不受影响,由 abort 负责终止)。"""
with _turns_lock:
_turns.pop(session_id, None)
+8 -8
View File
@@ -44,6 +44,9 @@ def _redirect_storage_roots(task_id: str) -> str:
- agent.utils.agent_utils.SESSIONS_ROOT → turn 文件、session_dir、get_history
- agent.history.tool_call_recorder.TOOL_CALLING_ROOT → tool_*.json、token
- web.ui_session.SESSIONS_ROOT → turn relpath 计算
同时将统一存储(agent.core.storage)的作用域切换为 "cron",
使消息/工具调用记录直接读写 cron 作用域(CRON_ROOT 文件 / cron_* 表),
无需执行后的文件→DB 桥接。
返回重定向后的任务目录绝对路径。
"""
@@ -57,6 +60,10 @@ def _redirect_storage_roots(task_id: str) -> str:
import web.ui_session as ui
ui.SESSIONS_ROOT = CRON_ROOT
# 统一存储:模块级便捷函数路由到 cron 作用域
from agent.core import storage as _storage
_storage.set_storage_scope("cron")
task_dir = os.path.join(CRON_ROOT, task_id)
os.makedirs(task_dir, exist_ok=True)
return task_dir
@@ -271,14 +278,7 @@ def main() -> int:
except Exception:
pass
# 6.5 MySQL 模式下把执行记录导入数据库(json 模式无操作,文件即存储)
try:
from agent.cron.storage import persist_turn_records
persist_turn_records(task_id, turn_id)
except Exception as e:
print(f"[cron.runner] 导入执行记录失败: {e}", file=sys.stderr)
# 7. 回传最终状态
# 7. 回传最终状态(执行记录已由统一存储按 scope=cron 直写,无需桥接导入)
_post_finished(task_id, status, error_msg, turn_id)
# 给转发线程一点时间发送最后的快照
+18 -755
View File
@@ -1,760 +1,23 @@
"""定时任务存储抽象层。
"""定时任务存储转发层(统一实现在 agent.core.storage)。
系统定位:
为 scheduler / API / cron_manager 工具提供统一的任务与记录读写接口。
支持两种后端:JSON 文件(history/cron/{task_id}/cron_task.json)与
MySQL(pymysql,表 cron_tasks / cron_messages / cron_tool_calls)。
可扩展性:
- get_task_repository() / get_record_repository() 依据 env_config.json 的
STORAGE_BACKEND(默认 "json")选择实现,两种后端均完整支持增删改查。
- 多模态附件地址在读取时解析为绝对地址(相对 CRON_ROOT),
保证数据库迁移到不同机器后仍可定位文件。
本模块保持既有导入路径(scheduler / cron_manager / server / runner),
实际实现与普通会话存储统一在 ``agent.core.storage``:
- ``get_record_repository()`` 固定返回 cron 作用域记录仓库
(cron_messages/cron_tool_calls 表,或 history/cron 下的 JSON 文件)。
- 新增存储后端只需在 ``agent.core.storage`` 实现 Protocol 并注册工厂。
"""
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from typing import Any, Protocol
from agent.cron.models import CronTask, ExecutionLog, now_iso
from agent.utils.agent_utils import HISTORY_ROOT
from agent.core.db import (
mysql_conn,
ensure_tables,
get_storage_backend,
from agent.core.storage import (
CRON_ROOT,
TaskConfigRepository,
ConversationRecordRepository,
get_task_repository,
new_task_id,
set_storage_scope,
get_storage_scope,
get_record_repository as _get_core_record_repository,
)
# 定时任务根目录:history/cron
CRON_ROOT = os.path.join(HISTORY_ROOT, "cron")
os.makedirs(CRON_ROOT, exist_ok=True)
# ---------- Protocol 定义 ----------
class CronTaskRepository(Protocol):
"""任务配置与状态的读写接口。"""
def list_tasks(self) -> list[CronTask]: ...
def get_task(self, task_id: str) -> CronTask | None: ...
def save_task(self, task: CronTask) -> None: ...
def delete_task(self, task_id: str) -> None: ...
def update_status(
self,
task_id: str,
status: str,
*,
error: str = "",
last_run_at: str = "",
next_run_at: str = "",
) -> None: ...
def append_execution(self, task_id: str, exec_log: dict[str, Any]) -> None: ...
class CronRecordRepository(Protocol):
"""任务执行记录(消息与工具调用)的读取接口。"""
def load_messages(self, task_id: str) -> list[dict]: ...
def list_turns(self, task_id: str) -> list[dict]: ...
def get_tool_calls(self, task_id: str, turn_id: str) -> list[dict]: ...
# ---------- JSON 实现 ----------
class JsonCronTaskRepository:
"""基于 JSON 文件的任务仓库。
存储路径: CRON_ROOT/{task_id}/cron_task.json
"""
def _task_dir(self, task_id: str) -> str:
return os.path.join(CRON_ROOT, task_id)
def _task_file(self, task_id: str) -> str:
return os.path.join(self._task_dir(task_id), "cron_task.json")
def list_tasks(self) -> list[CronTask]:
if not os.path.isdir(CRON_ROOT):
return []
tasks: list[CronTask] = []
for name in os.listdir(CRON_ROOT):
tf = self._task_file(name)
if not os.path.isfile(tf):
continue
task = self._load(tf)
if task:
tasks.append(task)
tasks.sort(key=lambda t: t.created_at, reverse=True)
return tasks
def get_task(self, task_id: str) -> CronTask | None:
return self._load(self._task_file(task_id))
def save_task(self, task: CronTask) -> None:
if not task.task_id:
return
d = self._task_dir(task.task_id)
os.makedirs(d, exist_ok=True)
with open(self._task_file(task.task_id), "w", encoding="utf-8") as f:
json.dump(task.to_dict(), f, ensure_ascii=False, indent=2)
def delete_task(self, task_id: str) -> None:
if not task_id:
return
d = self._task_dir(task_id)
if os.path.isdir(d):
shutil.rmtree(d, ignore_errors=True)
def update_status(
self,
task_id: str,
status: str,
*,
error: str = "",
last_run_at: str = "",
next_run_at: str = "",
) -> None:
task = self.get_task(task_id)
if not task:
return
task.last_status = status # type: ignore[assignment]
if error:
task.last_error = error
elif status != "failed":
# 成功类状态清空错误描述
task.last_error = ""
if last_run_at:
task.last_run_at = last_run_at
if next_run_at is not None:
task.next_run_at = next_run_at
self.save_task(task)
def append_execution(self, task_id: str, exec_log: dict[str, Any]) -> None:
task = self.get_task(task_id)
if not task:
return
task.executions.append(ExecutionLog.from_dict(exec_log))
self.save_task(task)
@staticmethod
def _load(path: str) -> CronTask | None:
if not os.path.isfile(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return CronTask.from_dict(json.load(f))
except (json.JSONDecodeError, OSError):
return None
class JsonCronRecordRepository:
"""基于 JSON 文件的执行记录仓库。
复用 session 同构的 turn_*.json / tool_*.json(由 runner 写入)。
读取时将附件 relpath 解析为绝对地址(相对 CRON_ROOT),保证可移植性。
"""
def _task_dir(self, task_id: str) -> str:
return os.path.join(CRON_ROOT, task_id)
def load_messages(self, task_id: str) -> list[dict]:
"""加载该任务所有 turn 的扁平消息列表,附件路径解析为绝对地址。"""
from agent.utils.agent_utils import TURN_JSON_PREFIX
sess_dir = self._task_dir(task_id)
if not os.path.isdir(sess_dir):
return []
all_msgs: list[dict] = []
turn_files = sorted(
f for f in os.listdir(sess_dir)
if f.startswith(TURN_JSON_PREFIX) and f.endswith(".json")
)
for tf in turn_files:
tp = os.path.join(sess_dir, tf)
if not os.path.isfile(tp):
continue
try:
with open(tp, "r", encoding="utf-8") as f:
data = json.load(f)
for msg in data.get("messages", []):
all_msgs.append(self._resolve_abs_paths(msg, task_id))
except (json.JSONDecodeError, OSError):
pass
return all_msgs
def list_turns(self, task_id: str) -> list[dict]:
"""列出该任务所有 turn 的元信息(turn_id + messages 概要)。"""
from agent.utils.agent_utils import TURN_JSON_PREFIX
sess_dir = self._task_dir(task_id)
if not os.path.isdir(sess_dir):
return []
turns: list[dict] = []
turn_files = sorted(
f for f in os.listdir(sess_dir)
if f.startswith(TURN_JSON_PREFIX) and f.endswith(".json")
)
for tf in turn_files:
tp = os.path.join(sess_dir, tf)
if not os.path.isfile(tp):
continue
try:
with open(tp, "r", encoding="utf-8") as f:
data = json.load(f)
turns.append({
"turn_id": data.get("turn_id", ""),
"messages_count": len(data.get("messages", [])),
})
except (json.JSONDecodeError, OSError):
pass
return turns
def get_tool_calls(self, task_id: str, turn_id: str) -> list[dict]:
"""读取指定 turn 的工具调用记录。"""
tool_path = os.path.join(self._task_dir(task_id), f"tool_{turn_id}.json")
if not os.path.isfile(tool_path):
return []
try:
with open(tool_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("tool_calls", []) or []
except (json.JSONDecodeError, OSError):
return []
def _resolve_abs_paths(self, msg: dict, task_id: str) -> dict:
"""附件路径解析委托给模块级 _resolve_msg_paths(JSON/MySQL 共用)。"""
return _resolve_msg_paths(msg, task_id)
# ---------- MySQL 实现(pymysql) ----------
# 连接 / 配置 / 后端选择已抽取至 agent.core.db,此处保留别名供模块内部调用
_mysql_conn = mysql_conn
_get_backend = get_storage_backend
# cron 三表 DDL(由 ensure_tables 幂等建表)
_CRON_TABLE_DDLS = [
"""
CREATE TABLE IF NOT EXISTS cron_tasks (
task_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
prompt TEXT NOT NULL,
trigger_type ENUM('cron','interval','once') NOT NULL,
cron_expr VARCHAR(128) DEFAULT '',
interval_seconds INT DEFAULT 0,
run_at VARCHAR(32) DEFAULT '',
enabled TINYINT(1) DEFAULT 1,
timeout_seconds INT DEFAULT 300,
created_at VARCHAR(32) NOT NULL,
next_run_at VARCHAR(32) DEFAULT '',
last_run_at VARCHAR(32) DEFAULT '',
last_status VARCHAR(16) DEFAULT 'pending',
last_error TEXT,
executions JSON DEFAULT NULL
) CHARACTER SET utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS cron_messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
content JSON NOT NULL,
meta JSON DEFAULT NULL,
INDEX idx_task_turn (task_id, turn_id)
) CHARACTER SET utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS cron_tool_calls (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
tool_calls JSON NOT NULL,
INDEX idx_task_turn (task_id, turn_id)
) CHARACTER SET utf8mb4
""",
]
def _ensure_mysql_tables() -> None:
"""幂等建 cron 三表(委托共享 ensure_tables)。"""
ensure_tables(_CRON_TABLE_DDLS)
def _row_to_task(row: dict[str, Any]) -> CronTask:
"""MySQL 行 → CronTask(构造与 to_dict 同构的 dict 再 from_dict)。"""
executions = row.get("executions")
if isinstance(executions, str):
try:
executions = json.loads(executions)
except (json.JSONDecodeError, TypeError):
executions = []
elif isinstance(executions, (bytes, bytearray)):
try:
executions = json.loads(executions.decode("utf-8"))
except Exception:
executions = []
elif not isinstance(executions, list):
executions = []
d = {
"task_id": row.get("task_id", ""),
"name": row.get("name", ""),
"prompt": row.get("prompt", ""),
"trigger": {
"type": row.get("trigger_type", "cron"),
"cron": row.get("cron_expr", "") or "",
"interval_seconds": int(row.get("interval_seconds", 0) or 0),
"run_at": row.get("run_at", "") or "",
},
"enabled": bool(row.get("enabled", 1)),
"timeout_seconds": int(row.get("timeout_seconds", 300) or 300),
"created_at": row.get("created_at", "") or "",
"next_run_at": row.get("next_run_at", "") or "",
"last_run_at": row.get("last_run_at", "") or "",
"last_status": row.get("last_status", "pending") or "pending",
"last_error": row.get("last_error", "") or "",
"executions": executions,
}
return CronTask.from_dict(d)
class MysqlCronTaskRepository:
"""基于 MySQL(pymysql)的任务仓库。
表: cron_tasks。trigger 拆为 trigger_type/cron_expr/interval_seconds/run_at 列,
executions 存为 JSON 列。
"""
def __init__(self) -> None:
_ensure_mysql_tables()
def list_tasks(self) -> list[CronTask]:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM cron_tasks ORDER BY created_at DESC")
rows = cur.fetchall() or []
return [_row_to_task(r) for r in rows]
finally:
conn.close()
def get_task(self, task_id: str) -> CronTask | None:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM cron_tasks WHERE task_id=%s", (task_id,))
row = cur.fetchone()
return _row_to_task(row) if row else None
finally:
conn.close()
def save_task(self, task: CronTask) -> None:
if not task.task_id:
return
trig = task.trigger
execs_json = json.dumps([e.to_dict() for e in task.executions], ensure_ascii=False)
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO cron_tasks
(task_id, name, prompt, trigger_type, cron_expr, interval_seconds, run_at,
enabled, timeout_seconds, created_at, next_run_at, last_run_at,
last_status, last_error, executions)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
name=VALUES(name), prompt=VALUES(prompt), trigger_type=VALUES(trigger_type),
cron_expr=VALUES(cron_expr), interval_seconds=VALUES(interval_seconds),
run_at=VALUES(run_at), enabled=VALUES(enabled),
timeout_seconds=VALUES(timeout_seconds), next_run_at=VALUES(next_run_at),
last_run_at=VALUES(last_run_at), last_status=VALUES(last_status),
last_error=VALUES(last_error), executions=VALUES(executions)
""",
(
task.task_id, task.name, task.prompt, trig.type, trig.cron,
trig.interval_seconds, trig.run_at, int(task.enabled),
task.timeout_seconds, task.created_at, task.next_run_at,
task.last_run_at, task.last_status, task.last_error, execs_json,
),
)
conn.commit()
finally:
conn.close()
def delete_task(self, task_id: str) -> None:
if not task_id:
return
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM cron_tasks WHERE task_id=%s", (task_id,))
cur.execute("DELETE FROM cron_messages WHERE task_id=%s", (task_id,))
cur.execute("DELETE FROM cron_tool_calls WHERE task_id=%s", (task_id,))
conn.commit()
finally:
conn.close()
def update_status(
self,
task_id: str,
status: str,
*,
error: str = "",
last_run_at: str = "",
next_run_at: str = "",
) -> None:
sets: list[str] = ["last_status=%s"]
vals: list[Any] = [status]
if error:
sets.append("last_error=%s")
vals.append(error)
elif status != "failed":
sets.append("last_error=%s")
vals.append("")
if last_run_at:
sets.append("last_run_at=%s")
vals.append(last_run_at)
# next_run_at 显式更新(空串也写,表示不再运行)
sets.append("next_run_at=%s")
vals.append(next_run_at)
vals.append(task_id)
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
f"UPDATE cron_tasks SET {', '.join(sets)} WHERE task_id=%s",
vals,
)
conn.commit()
finally:
conn.close()
def append_execution(self, task_id: str, exec_log: dict[str, Any]) -> None:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT executions FROM cron_tasks WHERE task_id=%s", (task_id,))
row = cur.fetchone()
execs: list = []
if row and row.get("executions"):
raw = row["executions"]
if isinstance(raw, str):
try:
execs = json.loads(raw)
except (json.JSONDecodeError, TypeError):
execs = []
elif isinstance(raw, list):
execs = raw
execs.append(exec_log)
cur.execute(
"UPDATE cron_tasks SET executions=%s WHERE task_id=%s",
(json.dumps(execs, ensure_ascii=False), task_id),
)
conn.commit()
finally:
conn.close()
class MysqlCronRecordRepository:
"""基于 MySQL(pymysql)的执行记录仓库。
表: cron_messages / cron_tool_calls。
附件路径统一存相对 CRON_ROOT 的 relpath,读取时按本机 CRON_ROOT 解析为绝对地址。
写入由 runner 执行后调用 persist_turn_records 触发(见模块底部)。
"""
def __init__(self) -> None:
_ensure_mysql_tables()
@staticmethod
def _parse_json_col(val: Any) -> Any:
if isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return None
if isinstance(val, (bytes, bytearray)):
try:
return json.loads(val.decode("utf-8"))
except Exception:
return None
return val
def load_messages(self, task_id: str) -> list[dict]:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT turn_id, role, content, meta FROM cron_messages "
"WHERE task_id=%s ORDER BY id",
(task_id,),
)
rows = cur.fetchall() or []
msgs: list[dict] = []
for r in rows:
content = self._parse_json_col(r.get("content")) or ""
meta = self._parse_json_col(r.get("meta"))
msg: dict[str, Any] = {"role": r.get("role", "user"), "content": content}
if meta:
msg["meta"] = meta
msgs.append(_resolve_msg_paths(msg, task_id))
return msgs
finally:
conn.close()
def list_turns(self, task_id: str) -> list[dict]:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT turn_id, COUNT(*) AS cnt FROM cron_messages "
"WHERE task_id=%s GROUP BY turn_id ORDER BY MIN(id)",
(task_id,),
)
rows = cur.fetchall() or []
return [{"turn_id": r.get("turn_id", ""), "messages_count": int(r.get("cnt", 0))} for r in rows]
finally:
conn.close()
def get_tool_calls(self, task_id: str, turn_id: str) -> list[dict]:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT tool_calls FROM cron_tool_calls "
"WHERE task_id=%s AND turn_id=%s ORDER BY id",
(task_id, turn_id),
)
rows = cur.fetchall() or []
out: list[dict] = []
for r in rows:
tc = self._parse_json_col(r.get("tool_calls"))
if isinstance(tc, list):
out.extend(tc)
return out
finally:
conn.close()
def save_turn(self, task_id: str, turn_id: str, messages: list[dict]) -> None:
"""写入一个 turn 的消息(幂等:先删同 turn 旧记录)。"""
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM cron_messages WHERE task_id=%s AND turn_id=%s",
(task_id, turn_id),
)
for msg in messages:
content = msg.get("content")
meta = msg.get("meta")
cur.execute(
"INSERT INTO cron_messages (task_id, turn_id, role, content, meta) "
"VALUES (%s,%s,%s,%s,%s)",
(
task_id, turn_id, msg.get("role", "user"),
json.dumps(content, ensure_ascii=False),
json.dumps(meta, ensure_ascii=False) if meta is not None else None,
),
)
conn.commit()
finally:
conn.close()
def save_tool_calls(self, task_id: str, turn_id: str, tool_calls: list[dict]) -> None:
"""写入一个 turn 的工具调用记录(幂等)。"""
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM cron_tool_calls WHERE task_id=%s AND turn_id=%s",
(task_id, turn_id),
)
cur.execute(
"INSERT INTO cron_tool_calls (task_id, turn_id, tool_calls) VALUES (%s,%s,%s)",
(task_id, turn_id, json.dumps(tool_calls, ensure_ascii=False)),
)
conn.commit()
finally:
conn.close()
# ---------- 附件路径解析(JSON / MySQL 共用) ----------
def _resolve_msg_paths(msg: dict, task_id: str) -> dict:
"""将消息 content 中的相对附件路径解析为绝对地址(相对 CRON_ROOT)。
runner 写入 / 数据库存储的附件路径均为相对 CRON_ROOT 的 relpath,
此处返回前端/序列化前还原为绝对路径,供文件读取。
"""
content = msg.get("content")
if not isinstance(content, list):
return msg
for piece in content:
if not isinstance(piece, dict):
continue
for key in ("path", "audio_path"):
p = piece.get(key)
if isinstance(p, str) and p and not os.path.isabs(p):
piece[key] = os.path.normpath(os.path.join(CRON_ROOT, p))
iu = piece.get("image_url")
if isinstance(iu, dict):
u = iu.get("url")
if isinstance(u, str) and u and not u.startswith("data:") and not os.path.isabs(u):
iu["url"] = os.path.normpath(os.path.join(CRON_ROOT, u))
return msg
def _to_rel_paths(msg: dict, task_id: str) -> dict:
"""_resolve_msg_paths 的逆操作:绝对路径 → 相对 CRON_ROOT 的 relpath(入库前调用)。"""
import copy
msg = copy.deepcopy(msg)
content = msg.get("content")
if not isinstance(content, list):
return msg
cron_root_abs = os.path.abspath(CRON_ROOT)
for piece in content:
if not isinstance(piece, dict):
continue
for key in ("path", "audio_path"):
p = piece.get(key)
if isinstance(p, str) and p and os.path.isabs(p):
try:
rel = os.path.relpath(p, cron_root_abs)
piece[key] = rel.replace("\\", "/")
except ValueError:
pass
iu = piece.get("image_url")
if isinstance(iu, dict):
u = iu.get("url")
if isinstance(u, str) and u and not u.startswith("data:") and os.path.isabs(u):
try:
rel = os.path.relpath(u, cron_root_abs)
iu["url"] = rel.replace("\\", "/")
except ValueError:
pass
return msg
# ---------- 工厂 ----------
_backend_cache: dict[str, Any] = {}
def get_task_repository() -> CronTaskRepository:
"""获取任务仓库实例(按配置选择 json / mysql)。"""
backend = _get_backend()
if backend == "mysql":
if "task_mysql" not in _backend_cache:
_backend_cache["task_mysql"] = MysqlCronTaskRepository()
return _backend_cache["task_mysql"] # type: ignore[return-value]
if "task_json" not in _backend_cache:
_backend_cache["task_json"] = JsonCronTaskRepository()
return _backend_cache["task_json"] # type: ignore[return-value]
def get_record_repository() -> CronRecordRepository:
"""获取执行记录仓库实例(按配置选择 json / mysql)。"""
backend = _get_backend()
if backend == "mysql":
if "record_mysql" not in _backend_cache:
_backend_cache["record_mysql"] = MysqlCronRecordRepository()
return _backend_cache["record_mysql"] # type: ignore[return-value]
if "record_json" not in _backend_cache:
_backend_cache["record_json"] = JsonCronRecordRepository()
return _backend_cache["record_json"] # type: ignore[return-value]
def new_task_id() -> str:
"""生成基于时间戳的任务 ID(与 session_id 同构风格)。"""
from datetime import datetime
base = datetime.now().strftime("%Y%m%d_%H%M%S")
existing = {t.task_id for t in get_task_repository().list_tasks()}
if base not in existing:
return base
n = 1
while True:
cand = f"{base}_{n}"
if cand not in existing:
return cand
n += 1
def persist_turn_records(task_id: str, turn_id: str) -> None:
"""将 runner 写入的 turn/tool 文件记录导入存储后端。
- JSON 后端:文件即存储,无需操作。
- MySQL 后端:读取 history/cron/{task_id}/turn_*.json 与 tool_*.json,
将附件路径转为相对 CRON_ROOT 的 relpath 后写入 cron_messages / cron_tool_calls。
由 runner.main() 在执行完成后调用。失败仅打印日志,不影响任务状态回传。
"""
if _get_backend() != "mysql" or not task_id or not turn_id:
return
try:
from agent.utils.agent_utils import TURN_JSON_PREFIX
sess_dir = os.path.join(CRON_ROOT, task_id)
# 读取 turn 消息
turn_path = os.path.join(sess_dir, f"{TURN_JSON_PREFIX}{turn_id}.json")
messages: list[dict] = []
if os.path.isfile(turn_path):
with open(turn_path, "r", encoding="utf-8") as f:
data = json.load(f)
messages = data.get("messages", []) or []
# 读取工具调用
tool_path = os.path.join(sess_dir, f"tool_{turn_id}.json")
tool_calls: list[dict] = []
if os.path.isfile(tool_path):
with open(tool_path, "r", encoding="utf-8") as f:
data = json.load(f)
tool_calls = data.get("tool_calls", []) or []
# 附件路径转相对(数据库存 relpath,保证可移植性)
messages = [_to_rel_paths(m, task_id) for m in messages]
repo = get_record_repository()
if hasattr(repo, "save_turn"):
repo.save_turn(task_id, turn_id, messages) # type: ignore[attr-defined]
if hasattr(repo, "save_tool_calls"):
repo.save_tool_calls(task_id, turn_id, tool_calls) # type: ignore[attr-defined]
except Exception as e:
import sys
print(f"[cron.storage] persist_turn_records 失败: {e}", file=sys.stderr)
# ---------- MySQL 表结构参考(已由 _ensure_mysql_tables 幂等建表实现) ----------
"""
CREATE TABLE cron_tasks (
task_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
prompt TEXT NOT NULL,
trigger_type ENUM('cron','interval','once') NOT NULL,
cron_expr VARCHAR(128) DEFAULT '',
interval_seconds INT DEFAULT 0,
run_at VARCHAR(32) DEFAULT '',
enabled TINYINT(1) DEFAULT 1,
timeout_seconds INT DEFAULT 300,
created_at VARCHAR(32) NOT NULL,
next_run_at VARCHAR(32) DEFAULT '',
last_run_at VARCHAR(32) DEFAULT '',
last_status VARCHAR(16) DEFAULT 'pending',
last_error TEXT,
executions JSON DEFAULT NULL
);
CREATE TABLE cron_messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
content JSON NOT NULL,
meta JSON DEFAULT NULL,
INDEX idx_task_turn (task_id, turn_id)
);
CREATE TABLE cron_tool_calls (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
tool_calls JSON NOT NULL,
INDEX idx_task_turn (task_id, turn_id)
);
-- 附件路径统一存储相对 CRON_ROOT 的 relpath,读取时按本机 CRON_ROOT 拼绝对路径。
"""
def get_record_repository() -> ConversationRecordRepository:
"""获取 cron 作用域的执行记录仓库实例(按 STORAGE_BACKEND 选择 json / mysql)。"""
return _get_core_record_repository(scope="cron") # type: ignore[return-value]
+32 -510
View File
@@ -1,512 +1,34 @@
"""普通会话历史的存储后端抽象(MySQL)。
"""会话历史存储转发层(统一实现在 agent.core.storage)。
系统定位:
为 web/ui_session、agent_utils、tool_call_recorder 提供会话消息与工具调用记录的
数据库读写入口。与 cron 共用 STORAGE_BACKEND 开关与 MYSQL_* 连接配置
(见 agent.core.db)。
设计要点(不双写:sql 就是 sql,json 就是 json):
- 选 mysql 时:对话消息与工具调用记录**只入库**(不写 turn_*.json / tool_*.json),
读取只走数据库;二进制产物文件(图片/附件等)仍按 json 方式落盘到 session 文件夹。
- 选 json 时:本模块所有操作退化为 no-op,读写仍走文件(现状不变)。
- 消息与工具调用记录**原样**入库(不做路径转换):DB 行为与文件读取完全一致,
下游 _serialize_content / _tool_calls_from_turn_record 无需感知数据来源。
- 写入幂等:save_turn / save_tool_calls 对同一 (session_id, turn_id) 先 DELETE 再 INSERT。
- sessions 表记录会话的 created_at/updated_at/last_turn_id,供列表排序与 get_last_turn_id。
- DB 失败仅打印日志,不阻断主流程(与 cron persist_turn_records 一致)。
可扩展性:
- 未来如需跨机迁移,可在此处增加 relpath 转换(参考 cron/storage._to_rel_paths)。
本模块保持既有导入路径(``from agent.history import session_storage``),
实际实现与 cron 存储统一在 ``agent.core.storage``:
- 消费方调用本模块的便捷函数即可,无需感知后端选择(json / mysql)。
- 新增存储后端只需在 ``agent.core.storage`` 实现 Protocol 并注册工厂。
"""
from __future__ import annotations
import json
import sys
from datetime import datetime
from typing import Any, Protocol
from agent.core.db import ensure_tables, get_storage_backend, mysql_conn
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
# ---------- 会话三表 DDL(由 ensure_tables 幂等建表) ----------
_SESSION_TABLE_DDLS = [
"""
CREATE TABLE IF NOT EXISTS sessions (
session_id VARCHAR(64) PRIMARY KEY,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
last_turn_id VARCHAR(64) DEFAULT ''
) CHARACTER SET utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS session_messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
seq INT NOT NULL DEFAULT 0,
message JSON NOT NULL,
INDEX idx_sess_turn (session_id, turn_id)
) CHARACTER SET utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS session_tool_calls (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
tool_calls JSON NOT NULL,
meta JSON DEFAULT NULL,
INDEX idx_sess_turn (session_id, turn_id)
) CHARACTER SET utf8mb4
""",
]
def is_mysql() -> bool:
"""当前会话存储后端是否为 mysql。"""
return get_storage_backend() == "mysql"
# ---------- Protocol ----------
class SessionRecordRepository(Protocol):
"""会话消息与工具调用记录的读写接口(mysql 后端实现)。"""
def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None: ...
def save_tool_calls(
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
) -> None: ...
def load_messages(self, session_id: str) -> list[dict]: ...
def list_session_ids(self) -> list[str]: ...
def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None: ...
def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None: ...
def list_turn_records(self, session_id: str, limit: int) -> list[dict[str, Any]]: ...
def get_session_extra(self, session_id: str) -> dict[str, Any]: ...
def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None: ...
def get_last_turn_id(self, session_id: str) -> str: ...
def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> None: ...
def delete_session(self, session_id: str) -> None: ...
# ---------- MySQL 实现 ----------
class MysqlSessionRecordRepository:
"""基于 MySQL(pymysql)的会话记录仓库。
表: sessions / session_messages / session_tool_calls。
消息与工具调用记录原样以 JSON 列存储;meta 列存 started_at/ended_at/duration 等。
"""
def __init__(self) -> None:
ensure_tables(_SESSION_TABLE_DDLS)
# 向后兼容:为已有 sessions 表补充 extra 列
self._ensure_extra_column()
def _ensure_extra_column(self) -> None:
"""幂等添加 sessions.extra JSON 列(存储 tokens + agent_meta)。"""
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"ALTER TABLE sessions ADD COLUMN extra JSON DEFAULT NULL"
)
conn.commit()
except Exception:
# 列已存在 → 忽略
pass
finally:
conn.close()
def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None:
if not session_id or not turn_id:
return
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM session_messages WHERE session_id=%s AND turn_id=%s",
(session_id, turn_id),
)
for seq, msg in enumerate(messages or []):
cur.execute(
"INSERT INTO session_messages (session_id, turn_id, seq, message) "
"VALUES (%s,%s,%s,%s)",
(session_id, turn_id, seq, json.dumps(msg, ensure_ascii=False)),
)
now = _now_iso()
cur.execute(
"""
INSERT INTO sessions (session_id, created_at, updated_at, last_turn_id)
VALUES (%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at), last_turn_id=VALUES(last_turn_id)
""",
(session_id, now, now, turn_id),
)
conn.commit()
finally:
conn.close()
def save_tool_calls(
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
) -> None:
if not session_id or not turn_id:
return
meta = meta or {}
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM session_tool_calls WHERE session_id=%s AND turn_id=%s",
(session_id, turn_id),
)
cur.execute(
"INSERT INTO session_tool_calls (session_id, turn_id, tool_calls, meta) "
"VALUES (%s,%s,%s,%s)",
(
session_id,
turn_id,
json.dumps(tool_calls or [], ensure_ascii=False),
json.dumps(meta, ensure_ascii=False) if meta else None,
),
)
conn.commit()
finally:
conn.close()
def load_messages(self, session_id: str) -> list[dict]:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT message FROM session_messages WHERE session_id=%s "
"ORDER BY turn_id ASC, seq ASC",
(session_id,),
)
rows = cur.fetchall() or []
return [self._loads(r["message"]) for r in rows]
finally:
conn.close()
def list_session_ids(self) -> list[str]:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT session_id FROM sessions ORDER BY updated_at DESC")
rows = cur.fetchall() or []
return [r["session_id"] for r in rows]
finally:
conn.close()
def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT tool_calls, meta FROM session_tool_calls "
"WHERE session_id=%s AND turn_id=%s",
(session_id, turn_id),
)
row = cur.fetchone()
if not row:
return None
return self._row_to_record(row, session_id, turn_id)
finally:
conn.close()
def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT turn_id, tool_calls, meta FROM session_tool_calls "
"WHERE session_id=%s ORDER BY turn_id DESC LIMIT 1",
(session_id,),
)
row = cur.fetchone()
if not row:
return None
return self._row_to_record(row, session_id, row["turn_id"])
finally:
conn.close()
def list_turn_records(self, session_id: str, limit: int) -> list[dict[str, Any]]:
"""按 turn_id 倒序返回最近 limit 条 turn 记录(与 tool_*.json 同构)。"""
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT turn_id, tool_calls, meta FROM session_tool_calls "
"WHERE session_id=%s ORDER BY turn_id DESC LIMIT %s",
(session_id, limit),
)
rows = cur.fetchall() or []
return [self._row_to_record(r, session_id, r["turn_id"]) for r in rows]
finally:
conn.close()
def get_session_extra(self, session_id: str) -> dict[str, Any]:
"""读取 sessions.extra JSON 列(含 tokens + agent_meta),不存在时返回 {}。"""
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT extra FROM sessions WHERE session_id=%s",
(session_id,),
)
row = cur.fetchone()
raw = (row or {}).get("extra")
return self._loads(raw) if raw is not None else {}
finally:
conn.close()
def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None:
"""写入 sessions.extra JSON 列;session 行不存在时先创建占位行。"""
conn = mysql_conn()
try:
with conn.cursor() as cur:
now = _now_iso()
cur.execute(
"""
INSERT INTO sessions (session_id, created_at, updated_at, extra)
VALUES (%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE updated_at=%s, extra=VALUES(extra)
""",
(session_id, now, now, json.dumps(extra, ensure_ascii=False), now),
)
conn.commit()
finally:
conn.close()
def get_last_turn_id(self, session_id: str) -> str:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT last_turn_id FROM sessions WHERE session_id=%s",
(session_id,),
)
row = cur.fetchone()
if row and row.get("last_turn_id"):
return row["last_turn_id"]
# 回退:从消息表取最大 turn_id
cur.execute(
"SELECT MAX(turn_id) AS m FROM session_messages WHERE session_id=%s",
(session_id,),
)
r2 = cur.fetchone()
return (r2 or {}).get("m") or ""
finally:
conn.close()
def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> None:
if not session_id:
return
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM session_messages WHERE session_id=%s AND turn_id > %s",
(session_id, keep_until_turn_id),
)
cur.execute(
"DELETE FROM session_tool_calls WHERE session_id=%s AND turn_id > %s",
(session_id, keep_until_turn_id),
)
# 更新 last_turn_id 为剩余最大 turn_id
cur.execute(
"SELECT MAX(turn_id) AS m FROM session_messages WHERE session_id=%s",
(session_id,),
)
r = cur.fetchone()
last = (r or {}).get("m") or ""
cur.execute(
"UPDATE sessions SET last_turn_id=%s, updated_at=%s WHERE session_id=%s",
(last, _now_iso(), session_id),
)
conn.commit()
finally:
conn.close()
def delete_session(self, session_id: str) -> None:
if not session_id:
return
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM session_messages WHERE session_id=%s", (session_id,))
cur.execute("DELETE FROM session_tool_calls WHERE session_id=%s", (session_id,))
cur.execute("DELETE FROM sessions WHERE session_id=%s", (session_id,))
conn.commit()
finally:
conn.close()
@staticmethod
def _loads(raw: Any) -> Any:
"""pymysql 对 JSON 列可能返回 dict 或 str,统一反序列化。"""
if isinstance(raw, str):
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
return raw
@classmethod
def _row_to_record(cls, row: dict[str, Any], session_id: str, turn_id: str) -> dict[str, Any]:
"""把 session_tool_calls 行还原为与 tool_*.json 同构的 record dict。"""
tool_calls = cls._loads(row.get("tool_calls")) or []
meta = cls._loads(row.get("meta")) if row.get("meta") is not None else {}
if not isinstance(meta, dict):
meta = {}
record: dict[str, Any] = {
"session_id": session_id,
"turn_id": turn_id,
"tool_calls": tool_calls,
"timestamp": turn_id,
}
# 合并 meta 中的 started_at/ended_at/duration/aborted 等
for k in ("started_at", "ended_at", "duration", "aborted"):
if k in meta:
record[k] = meta[k]
return record
# ---------- 工厂 ----------
_session_repo_cache: dict[str, Any] = {}
def get_session_record_repository() -> MysqlSessionRecordRepository:
"""获取会话记录仓库实例(仅在 mysql 后端调用,带缓存)。"""
if "session_mysql" not in _session_repo_cache:
_session_repo_cache["session_mysql"] = MysqlSessionRecordRepository()
return _session_repo_cache["session_mysql"] # type: ignore[return-value]
# ---------- 模块级便捷封装(内部自检 backend,json 后端 no-op / 返回 None) ----------
def persist_messages(session_id: str, turn_id: str, messages: list[dict]) -> None:
"""mysql 后端下把一轮消息写入 DB;json 后端 no-op。"""
if not is_mysql() or not session_id or not turn_id:
return
try:
get_session_record_repository().save_turn(session_id, turn_id, messages)
except Exception as e:
print(f"[session_storage] persist_messages 失败: {e}", file=sys.stderr)
def persist_tool_calls(
session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
) -> None:
"""mysql 后端下把一轮工具调用记录写入 DB;json 后端 no-op。"""
if not is_mysql() or not session_id or not turn_id:
return
try:
get_session_record_repository().save_tool_calls(session_id, turn_id, tool_calls, meta)
except Exception as e:
print(f"[session_storage] persist_tool_calls 失败: {e}", file=sys.stderr)
def load_messages(session_id: str) -> list[dict] | None:
"""mysql 后端返回消息列表;json 后端返回 None(调用方回退文件逻辑)。"""
if not is_mysql():
return None
try:
return get_session_record_repository().load_messages(session_id)
except Exception as e:
print(f"[session_storage] load_messages 失败: {e}", file=sys.stderr)
return None
def list_session_ids() -> list[str] | None:
"""mysql 后端返回会话 ID 列表(updated_at 倒序);json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().list_session_ids()
except Exception as e:
print(f"[session_storage] list_session_ids 失败: {e}", file=sys.stderr)
return None
def get_turn_record(session_id: str, turn_id: str) -> dict[str, Any] | None:
"""mysql 后端返回 turn 记录(与 tool_*.json 同构);json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().get_turn_record(session_id, turn_id)
except Exception as e:
print(f"[session_storage] get_turn_record 失败: {e}", file=sys.stderr)
return None
def get_latest_turn_record(session_id: str) -> dict[str, Any] | None:
"""mysql 后端返回最近 turn 记录;json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().get_latest_turn_record(session_id)
except Exception as e:
print(f"[session_storage] get_latest_turn_record 失败: {e}", file=sys.stderr)
return None
def list_turn_records(session_id: str, limit: int = 5) -> list[dict[str, Any]] | None:
"""mysql 后端返回最近 limit 条 turn 记录(turn_id 倒序);json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().list_turn_records(session_id, limit)
except Exception as e:
print(f"[session_storage] list_turn_records 失败: {e}", file=sys.stderr)
return None
def load_session_extra(session_id: str) -> dict[str, Any] | None:
"""mysql 后端返回 sessions.extra(tokens + agent_meta);json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().get_session_extra(session_id)
except Exception as e:
print(f"[session_storage] load_session_extra 失败: {e}", file=sys.stderr)
return None
def save_session_extra(session_id: str, extra: dict[str, Any]) -> None:
"""mysql 后端下写入 sessions.extra;json 后端 no-op。"""
if not is_mysql():
return
try:
get_session_record_repository().save_session_extra(session_id, extra)
except Exception as e:
print(f"[session_storage] save_session_extra 失败: {e}", file=sys.stderr)
def get_last_turn_id(session_id: str) -> str | None:
"""mysql 后端返回最后 turn_id;json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().get_last_turn_id(session_id)
except Exception as e:
print(f"[session_storage] get_last_turn_id 失败: {e}", file=sys.stderr)
return None
def delete_turns_after(session_id: str, keep_until_turn_id: str) -> None:
"""mysql 后端下删除 keep_until_turn_id 之后的 turn 记录;json 后端 no-op。"""
if not is_mysql():
return
try:
get_session_record_repository().delete_turns_after(session_id, keep_until_turn_id)
except Exception as e:
print(f"[session_storage] delete_turns_after 失败: {e}", file=sys.stderr)
def delete_session(session_id: str) -> None:
"""mysql 后端下删除会话全部记录;json 后端 no-op。"""
if not is_mysql():
return
try:
get_session_record_repository().delete_session(session_id)
except Exception as e:
print(f"[session_storage] delete_session 失败: {e}", file=sys.stderr)
from agent.core.storage import (
# Protocol 与实现
ConversationRecordRepository,
JsonRecordRepository,
MysqlRecordRepository,
# 便捷函数(后端无关)
save_turn,
load_messages,
load_turn_messages,
list_turn_ids,
save_tool_calls,
get_turn_record,
get_latest_turn_record,
list_turn_records,
persist_live_records,
load_session_extra,
save_session_extra,
list_session_ids,
get_last_turn_id,
delete_turns_after,
delete_tool_records,
delete_session,
# 作用域与工厂
set_storage_scope,
get_storage_scope,
get_record_repository,
)
+66 -234
View File
@@ -104,53 +104,20 @@ _session_token_breakdowns: dict[str, dict] = {} # session_id → {messages, too
_session_tokens_loaded: set[str] = set() # 已从磁盘加载过的 session_id
def _token_file(session_id: str) -> Path:
"""session 的 token 持久化文件路径。"""
return TOOL_CALLING_ROOT / session_id / "_session_tokens.json"
def _persist_token(session_id: str, tokens: int, breakdown: dict | None = None) -> None:
"""将 token 写入存储(优先 MySQL,回退 JSON 文件)。"""
# mysql 后端:写入 sessions.extra.tokens
try:
from agent.history import session_storage
if session_storage.is_mysql():
existing = session_storage.load_session_extra(session_id) or {}
existing["tokens"] = tokens
if breakdown:
existing["token_breakdown"] = breakdown
session_storage.save_session_extra(session_id, existing)
return
except Exception:
pass
# json 后端:写入文件
tf = _token_file(session_id)
tf.parent.mkdir(parents=True, exist_ok=True)
payload = {"session_id": session_id, "tokens": tokens}
"""将 token 写入存储(后端无关:mysql 存 sessions.extra,json 写 _session_tokens.json)。"""
from agent.history import session_storage
existing = session_storage.load_session_extra(session_id) or {}
existing["tokens"] = tokens
if breakdown:
payload["breakdown"] = breakdown
tf.write_text(json.dumps(payload), encoding="utf-8")
existing["token_breakdown"] = breakdown
session_storage.save_session_extra(session_id, existing)
def _load_token_from_disk(session_id: str) -> int:
"""从磁盘/数据库读取 token(若不存在返回 0)。"""
# mysql 后端:从 sessions.extra 读取
try:
from agent.history import session_storage
_db_extra = session_storage.load_session_extra(session_id)
if _db_extra is not None:
return int(_db_extra.get("tokens", 0))
except Exception:
pass
# json 后端:从文件读取
tf = _token_file(session_id)
if not tf.exists():
return 0
try:
data = json.loads(tf.read_text(encoding="utf-8"))
return int(data.get("tokens", 0))
except Exception:
return 0
"""从存储读取 token(若不存在返回 0)。"""
from agent.history import session_storage
return int(session_storage.load_session_extra(session_id).get("tokens", 0) or 0)
def set_session_tokens(session_id: str, total_tokens: int, breakdown: dict | None = None) -> None:
@@ -185,29 +152,16 @@ def get_session_tokens(session_id: str) -> int:
def get_session_token_breakdown(session_id: str) -> dict:
"""获取当前会话的三类 token 估算占比(消息/工具/系统提示词)。
内存优先,回退磁盘(JSON 文件的 breakdown / MySQL 的 token_breakdown)。
内存优先,回退存储(JSON 文件的 breakdown / MySQL 的 token_breakdown)。
"""
with _session_tokens_lock:
if session_id in _session_token_breakdowns:
return dict(_session_token_breakdowns[session_id])
try:
from agent.history import session_storage
if session_storage.is_mysql():
extra = session_storage.load_session_extra(session_id) or {}
bd = extra.get("token_breakdown")
if isinstance(bd, dict):
return bd
except Exception:
pass
tf = _token_file(session_id)
if tf.exists():
try:
data = json.loads(tf.read_text(encoding="utf-8"))
bd = data.get("breakdown")
if isinstance(bd, dict):
return bd
except Exception:
pass
from agent.history import session_storage
extra = session_storage.load_session_extra(session_id) or {}
bd = extra.get("token_breakdown")
if isinstance(bd, dict):
return bd
return {"messages": 0.0, "tools": 0.0, "system": 0.0}
@@ -216,21 +170,11 @@ def clear_session_tokens(session_id: str) -> None:
with _session_tokens_lock:
_session_tokens.pop(session_id, None)
_session_token_breakdowns.pop(session_id, None)
# mysql 后端:设置 tokens=0
try:
from agent.history import session_storage
if session_storage.is_mysql():
existing = session_storage.load_session_extra(session_id) or {}
existing["tokens"] = 0
existing.pop("token_breakdown", None)
session_storage.save_session_extra(session_id, existing)
return
except Exception:
pass
# json 后端:删除文件
tf = _token_file(session_id)
if tf.exists():
tf.unlink(missing_ok=True)
from agent.history import session_storage
existing = session_storage.load_session_extra(session_id) or {}
existing["tokens"] = 0
existing.pop("token_breakdown", None)
session_storage.save_session_extra(session_id, existing)
# ---------- 待附加的 thinking ----------
# 当 record_reflection_live 被调用时,thinking 暂存于此;
@@ -376,33 +320,20 @@ def record_tool_result_live(
def _safe_persist_live_records(session_id: str, records: list[dict[str, Any]]) -> None:
"""将实时记录增量写入磁盘(仅 json 后端),防止进程崩溃丢失数据。
"""将实时记录增量写入存储,防止进程崩溃丢失数据。
系统定位:
mysql 后端不写 tool_*.json 文件,由 save_turn 统一入库;此处直接跳过。
委托统一存储的 persist_live_records(可选行为:json 后端写 tool_*.json,
mysql 后端不增量入库,由 save_turn 在轮次结束时统一落库)。
"""
try:
from agent.history import session_storage
if session_storage.is_mysql():
return # mysql 后端:记录只入库,不写增量文件
except Exception:
pass
try:
turn_id = get_current_turn()
if not turn_id:
return
session_dir = session_tool_dir(session_id)
_ensure_dir(session_dir)
record = {
"session_id": session_id,
"turn_id": turn_id,
"tool_calls": records,
"timestamp": turn_id,
"started_at": _turn_start_times.get(session_id),
}
json_path = session_dir / f"tool_{turn_id}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(record, f, ensure_ascii=False, indent=2)
from agent.history import session_storage
session_storage.persist_live_records(
session_id, turn_id, records, started_at=_turn_start_times.get(session_id)
)
except Exception:
pass # 持久化失败不应影响主流程
@@ -571,12 +502,23 @@ def unsubscribe_live(session_id: str, q: _queue_module.Queue) -> None:
def _build_live_snapshot(session_id: str) -> dict[str, Any]:
"""构建当前实时状态的快照(不加锁,调用者需确保数据一致性或在锁外读取)。"""
return {
"""构建当前实时状态的快照(不加锁,调用者需确保数据一致性或在锁外读取)。
除工具调用/思考/轮次开始时间外,还附带当前等待中的人工请求
(human_requests),前端据此渲染确认浮窗。
"""
snapshot = {
"tool_calls": get_live_tool_calls(session_id),
"reflections": get_live_reflections(session_id),
"started_at": get_turn_started_at(session_id),
}
try:
# 惰性导入避免与 human_request(其模块级导入本模块) 循环依赖
from agent.core.human_request import get_human_requests_for_session
snapshot["human_requests"] = get_human_requests_for_session(session_id)
except Exception:
snapshot["human_requests"] = []
return snapshot
def _notify_live(session_id: str) -> None:
@@ -624,44 +566,15 @@ def save_aborted_turn(session_id: str, turn_id: str) -> None:
ended_at = time.time()
# 优先使用 _turn_start_times(轮次开始时刻),回退到 ended_at(确保 duration 不为负)
started_at = _turn_start_times.get(session_id) or ended_at
record = {
"session_id": session_id,
"turn_id": turn_id,
"user_message": {},
"tool_calls": normalized,
"assistant_response": "",
"timestamp": turn_id,
# 工具调用记录统一走存储(后端无关:mysql 只入库,json 只写文件,不双写)
from agent.history import session_storage
_meta = {
"started_at": started_at,
"ended_at": ended_at,
"duration": max(0, ended_at - started_at),
"aborted": True,
}
# 使用 tool_ 前缀(与 save_turn / _safe_persist_live_records 一致),
# 确保 get_tool_calls_for_turn 能正确读取
json_path = session_dir / f"tool_{turn_id}.json"
# 判定后端:mysql 只入库,json 只写文件(不双写)
try:
from agent.history import session_storage
_is_mysql = session_storage.is_mysql()
except Exception:
_is_mysql = False
_meta = {
"started_at": started_at,
"ended_at": ended_at,
"duration": record.get("duration"),
"aborted": True,
}
if _is_mysql:
# mysql 后端:工具调用记录只入库
try:
session_storage.persist_tool_calls(session_id, turn_id, normalized, meta=_meta)
except Exception as _e:
import sys as _sys
print(f"[tool_call_recorder] save_aborted_turn DB 写入失败: {_e}", file=_sys.stderr)
else:
# json 后端:工具调用记录只写文件
with open(json_path, "w", encoding="utf-8") as f:
json.dump(record, f, ensure_ascii=False, indent=2)
session_storage.save_tool_calls(session_id, turn_id, normalized, meta=_meta)
def get_live_tool_calls(session_id: str) -> list[dict[str, Any]]:
@@ -944,30 +857,16 @@ def save_turn(session_id: str, turn_id: str, user_message: dict[str, Any],
for fname, data_bytes in all_bytes_map.items():
(session_dir / fname).write_bytes(data_bytes)
# 判定后端:mysql 只入库,json 只写文件(不双写)
try:
from agent.history import session_storage
_is_mysql = session_storage.is_mysql()
except Exception:
_is_mysql = False
# 读取已有的 live 记录(含正确的 thinking/args_summary)
json_path = session_dir / f"tool_{turn_id}.json"
# 读取已有的 live 记录(含正确的 thinking/args_summary):
# 统一从存储读取(json 后端为实时增量写盘的文件,mysql 后端无增量记录 → None)
started_at = _turn_start_times.pop(session_id, None)
from agent.history import session_storage
live_record = session_storage.get_turn_record(session_id, turn_id)
live_calls: list[dict[str, Any]] = []
if _is_mysql:
# mysql 不写增量文件,直接从内存 live 记录读取用于合并(保留准确时间戳/thinking)
with _live_lock:
live_calls = list(_live_records.get(session_id, []))
elif json_path.is_file():
try:
with open(json_path, "r", encoding="utf-8") as f:
existing = json.load(f)
live_calls = existing.get("tool_calls") or []
if existing.get("started_at"):
started_at = existing["started_at"]
except Exception:
live_calls = []
if live_record:
live_calls = live_record.get("tool_calls") or []
if live_record.get("started_at"):
started_at = live_record["started_at"]
# 合并:以 extracted 为基础,从 live 记录中补充正确的 thinking/args_summary
tool_calls = _merge_extracted_with_live(extracted, live_calls)
@@ -976,32 +875,14 @@ def save_turn(session_id: str, turn_id: str, user_message: dict[str, Any],
_inject_sub_traces_to_record(tool_calls, session_id, turn_id)
ended_at = time.time()
record = {
"session_id": session_id,
"turn_id": turn_id,
"tool_calls": tool_calls,
"timestamp": turn_id,
# 工具调用记录统一走存储(后端无关:mysql 只入库,json 只写文件,不双写)
_meta = {
"started_at": started_at,
"ended_at": ended_at,
"duration": (ended_at - started_at) if started_at else None,
}
if _is_mysql:
# mysql 后端:工具调用记录只入库
try:
_meta = {
"started_at": started_at,
"ended_at": ended_at,
"duration": record.get("duration"),
}
session_storage.persist_tool_calls(session_id, turn_id, tool_calls, meta=_meta)
except Exception as _e:
import sys as _sys
print(f"[tool_call_recorder] save_turn DB 写入失败: {_e}", file=_sys.stderr)
else:
# json 后端:工具调用记录只写文件
with open(json_path, "w", encoding="utf-8") as f:
json.dump(record, f, ensure_ascii=False, indent=2)
return str(json_path)
session_storage.save_tool_calls(session_id, turn_id, tool_calls, meta=_meta)
return None
def _merge_extracted_with_live(
@@ -1062,52 +943,19 @@ def _inject_sub_traces_to_record(tool_calls: list[dict[str, Any]], session_id: s
def list_turns(session_id: str, limit: int = 5) -> list[dict[str, Any]]:
"""列出会话下所有 turn(按时间倒序,默认最近5轮)。
输入:
session_id: 会话 ID。
limit: 返回的最大轮数,默认 5。
"""
# mysql 后端:从数据库读取
try:
from agent.history import session_storage
_db_records = session_storage.list_turn_records(session_id, limit)
if _db_records is not None:
return _db_records
except Exception as _e:
import sys as _sys
print(f"[tool_call_recorder] list_turns DB 读取失败: {_e}", file=_sys.stderr)
# json 后端:从文件读取
session_dir = session_tool_dir(session_id)
if not session_dir.is_dir():
return []
turns = []
for f in sorted(session_dir.glob("tool_*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
turns.append(json.load(fh))
except (json.JSONDecodeError, OSError):
pass
return turns[:limit]
from agent.history import session_storage
return session_storage.list_turn_records(session_id, limit)
def get_latest_turn(session_id: str) -> dict[str, Any] | None:
"""获取最近一个 turn 的完整记录。
系统定位:
mysql 后端从数据库读取;json 后端从文件读取(list_turns[0])。
"""
# mysql 后端:从数据库读取
try:
from agent.history import session_storage
_db_rec = session_storage.get_latest_turn_record(session_id)
if _db_rec is not None:
return _db_rec
except Exception as _e:
import sys as _sys
print(f"[tool_call_recorder] get_latest_turn DB 读取失败: {_e}", file=_sys.stderr)
# json 后端:从文件读取
turns = list_turns(session_id)
return turns[0] if turns else None
"""获取最近一个 turn 的完整记录(后端无关)。"""
from agent.history import session_storage
return session_storage.get_latest_turn_record(session_id)
def get_tool_calls_for_latest_turn(session_id: str) -> list[dict[str, Any]]:
@@ -1119,32 +967,16 @@ def get_tool_calls_for_latest_turn(session_id: str) -> list[dict[str, Any]]:
def _load_turn_record(session_id: str, turn_id: str) -> dict[str, Any] | None:
"""读取指定 turn_id 的工具调用记录(mysql 走 DB,json 走文件)。
"""读取指定 turn_id 的工具调用记录(后端无关)。
系统定位:
get_tool_calls_for_turn / get_turn_meta / get_thinking_for_turn 共用的
后端感知读取入口。返回与 tool_*.json 同构的 record dict。
读取入口。返回与 tool_*.json 同构的 record dict;不存在返回 None。
"""
# mysql 后端:从数据库读取(无行返回 None,不回退文件——旧会话未迁移)
try:
from agent.history import session_storage
_db_rec = session_storage.get_turn_record(session_id, turn_id)
if _db_rec is not None:
return _db_rec
except Exception as _e:
import sys as _sys
print(f"[tool_call_recorder] _load_turn_record DB 读取失败: {_e}", file=_sys.stderr)
# json 后端:从文件读取
if not turn_id:
return None
json_path = session_tool_dir(session_id) / f"tool_{turn_id}.json"
if not json_path.is_file():
return None
try:
with open(json_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
from agent.history import session_storage
return session_storage.get_turn_record(session_id, turn_id)
def get_tool_calls_for_turn(session_id: str, turn_id: str) -> list[dict[str, Any]]:
+13 -5
View File
@@ -422,8 +422,10 @@ def parse_skill_zip(zip_bytes: bytes) -> dict | None:
except KeyError:
return None
meta, body = _parse_frontmatter(md_text)
# 附件:一级子目录下除 skill.md/skill.json 之外的其余文件(不深入展开子目录内部)
# 附件:一级目录下除 skill.md/skill.json 之外的树(不深入展开,
# 子文件夹内的文件折叠为 "子文件夹/" 一级展示,与文件夹导入行为一致)
attachments = []
seen_first = set()
for n in zf.namelist():
if n.endswith("/") or n == md_entry:
continue
@@ -431,13 +433,19 @@ def parse_skill_zip(zip_bytes: bytes) -> dict | None:
if top_dir:
if parts[0] != top_dir:
continue
rel = "/".join(parts[1:])
rel_parts = parts[1:]
else:
rel = n
rel_parts = parts
if not rel_parts:
continue
rel = "/".join(rel_parts)
if rel.lower() in ("skill.md", "skill.json"):
continue
attachments.append(rel)
attachments.sort()
if len(rel_parts) == 1:
seen_first.add(rel)
else:
seen_first.add(rel_parts[0] + "/")
attachments = sorted(seen_first)
return {
"name": meta.get("name", ""),
"description": meta.get("description", ""),
+10 -81
View File
@@ -5,6 +5,9 @@
调用子 Agent 执行子任务。支持一轮中发起多个 call_subagent 调用,
结合并行 ToolNode 实现子 Agent 并行执行。
子 Agent 内的人机交互为阻塞式普通工具:子图在线程中阻塞等待前端应答,
完成后自然回到主图,无需 pending 冒泡/续跑机制。
工具签名:
call_subagent(agent_name, task_description) -> str
@@ -25,22 +28,6 @@ from agent.core.state import AgentState
from agent.utils.tool_call_utils import invoke_tool_and_build_message
class SubAgentPendingError(Exception):
"""子 Agent 执行中遇到 HITL/确认工具时抛出,冒泡到主图捕获。
携带 pending_meta(含 approval_id)与 agent_name;
main_messages / main_tool_call_id 由 parallel_tool_node 捕获时填充,
供主图在用户确认/跳过后恢复执行。
"""
def __init__(self, pending_meta: dict, agent_name: str = ""):
super().__init__(f"sub-agent '{agent_name}' pending human action")
self.pending_meta = pending_meta
self.agent_name = agent_name
self.main_messages = None # 由 parallel_tool_node 填充:主图消息快照
self.main_tool_call_id = None # 由 parallel_tool_node 填充:call_subagent 的 tool_call_id
class CallSubAgentInput(BaseModel):
"""call_subagent 工具的输入参数。"""
agent_name: str = Field(
@@ -163,8 +150,8 @@ class CallSubAgentTool(BaseTool):
config = {"recursion_limit": runtime.max_iterations}
# 设置子 Agent 上下文:TodoList 按 agent 隔离时使用子 Agent 名称
from agent.tools.todo_list import set_current_agent_name
# 设置子 Agent 上下文:agent 名称供前端浮窗按 agent 分组
from agent.core.human_request import set_current_agent_name
set_current_agent_name(agent_name)
# 进入子 Agent 执行上下文:抑制其 record_*_live 调用,
# 避免子 Agent 的思考/工具调用混入主 Agent 的实时列表。
@@ -178,17 +165,6 @@ class CallSubAgentTool(BaseTool):
# 提取子 Agent 工具调用轨迹 → 存档供前端嵌套展开
_record_sub_trace(agent_name, all_messages)
# ===== 子 Agent HITL 冒泡检测(Part 2)=====
# 子图在 confirm/human_interaction 工具处会 END;检测是否产生 pending,
# 若有则向 PENDING_TOOL_APPROVALS 打 agent_name 标记并抛 SubAgentPendingError,
# 由主图捕获后弹窗(前端按 agent_name 分组显示)。
from agent.history.tool_call_recorder import get_current_session
sub_session_id = get_current_session() or ""
sub_pending_meta = _detect_sub_pending(all_messages, sub_session_id, runtime, agent_name)
if sub_pending_meta is not None:
# 抛出前还原主 Agent 上下文(finally 会再次还原,此处冗余但安全)
raise SubAgentPendingError(sub_pending_meta, agent_name)
# 提取最后一条非工具调用的助手消息作为最终结果
from langchain_core.messages import AIMessage
final_text = ""
@@ -211,8 +187,6 @@ class CallSubAgentTool(BaseTool):
return f"[子Agent: {agent_name}] 执行结果:\n{final_text}"
except SubAgentPendingError:
raise
except Exception as e:
return f"[子Agent: {agent_name}] 执行出错: {str(e)}"
finally:
@@ -226,34 +200,6 @@ class CallSubAgentTool(BaseTool):
# ---------- 子 Agent 轨迹记录 ----------
def _detect_sub_pending(all_messages: list, session_id: str, runtime: Any, agent_name: str) -> dict | None:
"""检测子 Agent 图是否在 confirm/human_interaction 处暂停。
若是,向 PENDING_TOOL_APPROVALS 对应条目打 agent_name/is_sub_agent 标记,
并附加 sub_runtime/sub_messages(原始 BaseMessage 列表)供主图恢复时使用。
返回 pending_meta 或 None。
"""
try:
from agent.core.routing import first_pending_tool_meta
from agent.core.approvals import PENDING_TOOL_APPROVALS
meta = first_pending_tool_meta(all_messages, session_id)
if not meta:
return None
pending_action = meta.get("pending_action") or {}
approval_id = pending_action.get("id")
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
entry = PENDING_TOOL_APPROVALS[approval_id]
entry["agent_name"] = agent_name
entry["is_sub_agent"] = True
entry["sub_runtime"] = runtime
entry["sub_messages"] = list(all_messages) # 原始 BaseMessage 列表,供恢复
return meta
except SubAgentPendingError:
raise
except Exception:
return None
def _record_sub_trace(agent_name: str, messages: list) -> None:
"""记录子 Agent 的工具调用轨迹,供前端嵌套展开。
@@ -304,7 +250,6 @@ def make_parallel_tool_node(tools: list[Any], max_workers: int = 5):
委托 invoke_tool_and_build_message 统一处理返回类型。
返回 (tool_name, content, artifact),artifact 为 None 表示无产物。
SubAgentPendingError 不被吞掉,向上冒泡到 parallel_tool_node。
"""
tool = tools_by_name.get(tool_name)
if tool is None:
@@ -312,8 +257,6 @@ def make_parallel_tool_node(tools: list[Any], max_workers: int = 5):
try:
content, artifact = invoke_tool_and_build_message(tool, tool_name, tool_args, tool_call_id)
return tool_name, content, artifact
except SubAgentPendingError:
raise # 子 Agent HITL 冒泡,主图需暂停
except Exception as e:
return tool_name, f"工具执行出错: {e}", None
@@ -326,8 +269,6 @@ def make_parallel_tool_node(tools: list[Any], max_workers: int = 5):
return {}
tool_calls = last_msg.tool_calls
# 主图消息快照:若子 Agent 触发 HITL 冒泡,供主图恢复时使用
main_messages_snapshot = list(state["messages"])
# 只有 1 个工具调用时直接串行执行,避免线程开销
if len(tool_calls) == 1:
@@ -335,12 +276,7 @@ def make_parallel_tool_node(tools: list[Any], max_workers: int = 5):
tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")
tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", "")
try:
name, content, artifact = _execute_single(tc_name, tc_args, tc_id)
except SubAgentPendingError as e:
e.main_messages = main_messages_snapshot
e.main_tool_call_id = tc_id
raise
name, content, artifact = _execute_single(tc_name, tc_args, tc_id)
msg_kwargs = {"content": str(content), "tool_call_id": tc_id, "name": name}
if artifact:
msg_kwargs["artifact"] = artifact
@@ -369,10 +305,8 @@ def make_parallel_tool_node(tools: list[Any], max_workers: int = 5):
if meta["id"] == tc_id:
try:
_, content, artifact = future.result()
except SubAgentPendingError as e:
e.main_messages = main_messages_snapshot
e.main_tool_call_id = tc_id
raise
except Exception:
content, artifact = f"工具执行出错: {tc_name}", None
msg_kwargs = {"content": str(content), "tool_call_id": tc_id, "name": tc_name}
if artifact:
msg_kwargs["artifact"] = artifact
@@ -381,13 +315,8 @@ def make_parallel_tool_node(tools: list[Any], max_workers: int = 5):
break
if not matched:
# 降级:尝试单次执行
try:
name, content, artifact = _execute_single(tc_name, {}, tc_id)
except SubAgentPendingError as e:
e.main_messages = main_messages_snapshot
e.main_tool_call_id = tc_id
raise
msg_kwargs = {"content": str(content), "tool_call_id": tc_id, "name": tc_name}
name, content, artifact = _execute_single(tc_name, {}, tc_id)
msg_kwargs = {"content": str(content), "tool_call_id": tc_id, "name": name}
if artifact:
msg_kwargs["artifact"] = artifact
tool_messages.append(ToolMessage(**msg_kwargs))
+100 -7
View File
@@ -28,8 +28,48 @@ class HumanInteractionInput(BaseModel):
)
def normalize_human_interaction_args(args: dict) -> dict:
"""规范化 human_interaction 工具参数为固定 schema。
输入:
args: 模型传入的原始参数字典。
输出:
含 interaction_type、title、prompt、options、suggested_response、questions 的字典;
interaction_type 限定为 information | selection。
"""
normalized = dict(args or {})
normalized["interaction_type"] = str(normalized.get("interaction_type") or "information")
if normalized["interaction_type"] not in {"information", "selection"}:
normalized["interaction_type"] = "information"
normalized["title"] = str(normalized.get("title") or "需要人工处理")
normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。")
normalized["options"] = HumanInteraction.normalize_options(normalized.get("options"))
normalized["suggested_response"] = str(normalized.get("suggested_response") or "")
# 规范化 questions 字段
questions = normalized.get("questions")
if isinstance(questions, str):
try:
questions = json.loads(questions)
except (json.JSONDecodeError, TypeError):
questions = None
if isinstance(questions, list):
normalized_questions = []
for q in questions:
if isinstance(q, dict):
nq = {
"question": str(q.get("question", "")),
"options": HumanInteraction.normalize_options(q.get("options")),
}
normalized_questions.append(nq)
normalized["questions"] = normalized_questions if normalized_questions else None
else:
normalized["questions"] = None
return normalized
class HumanInteraction(BaseTool):
"""向人类请求补充信息或选择;实际 UI 确认由图级人工干预层承接。"""
"""向人类请求补充信息或选择;调用时阻塞等待前端浮窗应答,答案作为普通工具返回值。"""
args_schema: type[BaseModel] = HumanInteractionInput
name: str = "human_interaction"
@@ -62,9 +102,6 @@ class HumanInteraction(BaseTool):
def format_result(interaction_type: str, options: list[str] | None = None, response: str = "") -> str:
"""根据交互类型与用户决策生成结果文本。
供 ``_run``(工具直接执行时的默认结果)与 ``approvals.human_interaction_result``
(用户确认后的真实结果)共用,消除重复的分支逻辑。
输入:
interaction_type: information | selection。
options: selection 的可选项(用于默认值)。
@@ -88,8 +125,45 @@ class HumanInteraction(BaseTool):
pass
selected = text or (options[0] if options else "")
return f"用户选择:{selected}" if selected else "用户选择:"
if text.startswith("用户拒绝") or text.startswith("用户已跳过"):
return text
return f"用户补充信息如下:{text}" if text else "用户没有补充信息"
@staticmethod
def build_request_meta(args: dict) -> dict:
"""将规范化后的工具参数构造成前端浮窗渲染数据(与旧 pending_action 形状一致)。"""
args = normalize_human_interaction_args(args)
interaction_type = args["interaction_type"]
default_titles = {
"information": "请补充信息",
"selection": "请选择一个选项",
}
agent_name = "主Agent"
try:
from agent.core.human_request import get_current_agent_name
_ag = get_current_agent_name()
agent_name = "主Agent" if _ag in ("", "__main__") else _ag
except Exception:
pass
return {
"type": "human_interaction",
"interaction_type": interaction_type,
"title": args["title"] or default_titles.get(interaction_type, "需要人工处理"),
"prompt": args["prompt"] or "请处理后继续。",
"tool_name": "human_interaction",
"args": {
"交互类型": interaction_type,
"说明": args["prompt"] or "请处理后继续。",
"可选项": args["options"] or [],
},
"instruction": args["suggested_response"] or "",
"options": args["options"] or [],
"questions": args["questions"] or None,
"agent_name": agent_name,
"is_sub_agent": agent_name != "主Agent",
"resolved": False,
}
def _run(
self,
interaction_type: str = "information",
@@ -97,10 +171,28 @@ class HumanInteraction(BaseTool):
prompt: str = "请处理后继续。",
options: Optional[list[str] | str] = None,
suggested_response: Optional[str] = None,
questions: Optional[list[dict]] = None,
) -> str:
try:
normalized_options = self.normalize_options(options)
return self.format_result(interaction_type, normalized_options, suggested_response or "")
args = {
"interaction_type": interaction_type,
"title": title,
"prompt": prompt,
"options": options,
"suggested_response": suggested_response,
"questions": questions,
}
normalized = normalize_human_interaction_args(args)
from agent.core.human_request import ask_human
from agent.history.tool_call_recorder import get_current_session
session_id = get_current_session() or ""
answer = ask_human(session_id, self.build_request_meta(normalized))
# 拒绝/跳过:原样返回用户决策文本,让 Agent 自行调整,不结束整轮
if answer.get("decision") in ("reject", "skip"):
return f"用户{('拒绝' if answer['decision'] == 'reject' else '已跳过')}此次交互:{answer.get('instruction') or ''}".strip()
return self.format_result(interaction_type, normalized.get("options") or [], answer.get("instruction") or "")
except RuntimeError:
raise # ABORTED_BY_USER 冒泡到 runtime 统一按暂停处理
except Exception as e:
return f"[ERROR] human_interaction 工具执行出错: {str(e)}"
@@ -111,5 +203,6 @@ class HumanInteraction(BaseTool):
prompt: str = "请处理后继续。",
options: Optional[list[str] | str] = None,
suggested_response: Optional[str] = None,
questions: Optional[list[dict]] = None,
) -> str:
return await asyncio.to_thread(self._run, interaction_type, title, prompt, options, suggested_response)
return await asyncio.to_thread(self._run, interaction_type, title, prompt, options, suggested_response, questions)
+3 -1
View File
@@ -90,9 +90,11 @@ class SkillRouter(BaseTool):
meta = metas.get(name, {})
content = contents[name]
# 将 {SKILL_DIR} 替换为实际 skills 目录路径
content = content.replace("{SKILL_DIR}", skill_dir_str + "/" + name)
skill_path = skill_dir_str + "/" + name
content = content.replace("{SKILL_DIR}", skill_path)
parts.append(
f"## Skill: {name}\n"
f"Skill 文件夹: {skill_path}\n"
f"描述: {meta.get('description', '')}\n\n"
f"{content}\n"
)
+63 -143
View File
@@ -1,123 +1,60 @@
from __future__ import annotations
import json
import threading
from contextvars import ContextVar
from typing import Any, Optional, Literal
from typing import Any, Literal, Optional
from langchain.tools import BaseTool
from pydantic import BaseModel, Field, model_validator
# ---------- 当前 agent 上下文(用于 TodoList 按 agent 隔离)----------
# 主 Agent 使用保留键 "__main__";子 Agent 使用其 agent_name。
# ContextVar + 全局 fallback 双轨:主线程/线程池子进程均可读到正确值;
# HTTP 请求线程(前端轮询)无上下文 → fallback 默认 "__main__" → 只见主 Agent。
_current_agent_name: ContextVar[str] = ContextVar("current_agent_name", default="")
_agent_name_fallback_lock = threading.Lock()
_current_agent_name_fallback = "__main__"
def _iter_todo_records(records: list[dict[str, Any]] | None):
"""遍历 live 工具记录中的 todo_list 调用(含子 Agent 嵌套在 call_subagent 内的记录)。"""
for rec in records or []:
if rec.get("name") == "todo_list":
yield rec
for sub in rec.get("sub_tool_calls") or []:
if sub.get("name") == "todo_list":
yield sub
def set_current_agent_name(agent_name: str) -> None:
"""设置当前线程的 agent 名称(主 Agent 传 "__main__",子 Agent 传其名称)。"""
global _current_agent_name_fallback
_current_agent_name.set(agent_name or "")
with _agent_name_fallback_lock:
_current_agent_name_fallback = agent_name or "__main__"
def _reconstruct_state(sid: str, steps_arg: Optional[list[str]], done_step_arg: int) -> tuple[list[str], set[int]]:
"""从当前轮的 live 工具记录重建累计 todo 状态。
def get_current_agent_name() -> str:
"""获取当前 agent 名称;ContextVar 为空时回退到全局 fallback(默认 "__main__")。"""
name = _current_agent_name.get()
if name:
return name
with _agent_name_fallback_lock:
return _current_agent_name_fallback or "__main__"
# ---------- Todo List 实时推送共享存储 ----------
# TodoList 工具本身和 web server 共享此 dict,无需跨模块 import server
# 二级键:外层 session_id,内层 agent_name(主 Agent 用 "__main__")
_todo_store: dict[str, dict[str, dict]] = {}
def _resolve_agent(agent_name: str | None) -> str:
return agent_name or get_current_agent_name() or "__main__"
def get_todo_store_data(session_id: str, agent_name: str | None = None) -> dict | None:
"""读取指定 session + agent 的 todo。
前端轮询时不传 agent_name → 走 get_current_agent_name() → HTTP 线程回退到 "__main__",
因此前端只见主 Agent 的 todo;子 Agent 的 todo 隔离在内层键中不暴露。
steps 取自最近一次传入完整步骤列表的调用;done_steps 累积全部 done_step。
本工具是纯函数、不维护任何全局 store——每次调用都从框架的 live 记录通道
读取自身调用历史,前端浮窗亦由同一通道(工具调用的 args)驱动更新。
"""
if not session_id:
return None
ag = _resolve_agent(agent_name)
return (_todo_store.get(session_id) or {}).get(ag)
def update_todo_store_session(
session_id: str,
steps: list[str] | None = None,
done_step: int = -1,
agent_name: str | None = None,
) -> dict | None:
if not session_id:
return None
ag = _resolve_agent(agent_name)
sess = _todo_store.setdefault(session_id, {})
current = sess.get(ag) or {"steps": [], "done_steps": []}
stored_steps = list(current.get("steps") or [])
done_steps = list(current.get("done_steps") or [])
if steps:
stored_steps = list(steps)
done_steps = []
if stored_steps and 1 <= done_step <= len(stored_steps):
for i in range(done_step):
if i not in done_steps:
done_steps.append(i)
done_steps.sort()
todo_data = {"steps": stored_steps, "done_steps": done_steps}
sess[ag] = todo_data
return todo_data
def update_todo_store_from_tool_args(session_id: str, tool_args: dict[str, Any], agent_name: str | None = None) -> dict | None:
raw_steps = tool_args.get("steps") if isinstance(tool_args, dict) else None
if isinstance(raw_steps, str):
steps: list[str] = []
done_steps: set[int] = set()
if sid:
try:
raw_steps = json.loads(raw_steps)
except (json.JSONDecodeError, ValueError):
raw_steps = None
steps = raw_steps if isinstance(raw_steps, list) else None
done_step = tool_args.get("done_step", -1) if isinstance(tool_args, dict) else -1
try:
done_step = int(done_step)
except (TypeError, ValueError):
done_step = -1
return update_todo_store_session(session_id, steps, done_step, agent_name=agent_name)
def set_todo_store_data(session_id: str, todo_data: dict, agent_name: str | None = None) -> None:
if not session_id:
return
ag = _resolve_agent(agent_name)
sess = _todo_store.setdefault(session_id, {})
sess[ag] = {
"steps": list(todo_data.get("steps") or []),
"done_steps": list(todo_data.get("done_steps") or []),
}
def clear_todo_store_session(session_id: str) -> None:
"""清除指定 session 下所有 agent 的 todo(主 + 各子 Agent)。"""
_todo_store.pop(session_id, None)
from agent.history.tool_call_recorder import get_live_tool_calls
for rec in _iter_todo_records(get_live_tool_calls(sid)):
args = rec.get("args") or {}
raw_steps = args.get("steps")
if isinstance(raw_steps, str):
try:
raw_steps = json.loads(raw_steps)
except (json.JSONDecodeError, ValueError):
raw_steps = None
if isinstance(raw_steps, list) and raw_steps:
steps = [str(s) for s in raw_steps]
done_steps = set()
try:
n = int(args.get("done_step", -1))
except (TypeError, ValueError):
n = -1
if steps and n >= 1:
done_steps.update(range(min(n, len(steps))))
except Exception:
pass
# 本次调用的参数覆盖/追加(live 记录不可用时的兜底)
if steps_arg:
steps = [str(s) for s in steps_arg]
done_steps = set()
if steps and done_step_arg >= 1:
done_steps.update(range(min(done_step_arg, len(steps))))
return steps, done_steps
class TodoListInput(BaseModel):
@@ -149,6 +86,9 @@ class TodoList(BaseTool):
Plan 模式下此工具为**必须调用**,Agent 模式下为可选。
首次调用时传入完整步骤列表,后续仅传 done_step 标记进度。
调用时会随 live 工具记录推送状态,前端据此实时渲染 Todo 浮窗;
返回值是纯文本,不携带任何结构化前缀。
"""
args_schema: type[BaseModel] = TodoListInput
@@ -159,15 +99,15 @@ class TodoList(BaseTool):
"参数说明:\n"
"- steps (list[str], 可选):任务步骤列表。仅在首次创建计划时传入所有步骤。\n"
" **步骤粒度要求**:同一页面/界面上的连续操作应合并为一条步骤,用\"并\"连接。例如:\n"
' * 推荐:\"点击搜索框,输入XXX并点击搜索\"(合并了点击、输入、搜索)\n'
' * 推荐:\"在结果中点击目标进入主页\"(合并了查找+点击)\n'
' * 禁止:\"1.点击搜索框 2.输入文字 3.点击搜索\"(过于细碎)\n'
' * 推荐:"点击搜索框,输入XXX并点击搜索"(合并了点击、输入、搜索)\n'
' * 推荐:"在结果中点击目标进入主页"(合并了查找+点击)\n'
' * 禁止:"1.点击搜索框 2.输入文字 3.点击搜索"(过于细碎)\n'
"- done_step (int, 可选):当前已推进到的步骤编号(从1开始)。传入 n 则步骤 1~n 全部标记为已完成。不传或传 -1 则只查看当前状态。\n"
"调用示例:\n"
'- 首次创建(B站搜索示例):{\"steps\": [\"打开哔哩哔哩网站\", \"点击顶部搜索框,输入\'燕三嘤嘤嘤\'并点击搜索\", \"在搜索结果中找到燕三嘤嘤嘤并点击头像或名称进入他的主页\", \"在他的主页点开视频列表里的第一个视频\", \"验证视频是否正在播放\"], \"done_step\": -1}\n'
'- 已完成前2步:{\"done_step\": 2} (步骤1和2均标记为完成)\n'
'- 已完成前4步:{\"done_step\": 4}\n'
'- 查看状态:{\"done_step\": -1}\n'
'- 首次创建(B站搜索示例):{"steps": ["打开哔哩哔哩网站", "点击顶部搜索框,输入\'燕三嘤嘤嘤\'并点击搜索", "在搜索结果中找到燕三嘤嘤嘤并点击头像或名称进入他的主页", "在他的主页点开视频列表里的第一个视频", "验证视频是否正在播放"], "done_step": -1}\n'
'- 已完成前2步:{"done_step": 2} (步骤1和2均标记为完成)\n'
'- 已完成前4步:{"done_step": 4}\n'
'- 查看状态:{"done_step": -1}\n'
"任务全部完成后无需再次调用。"
)
response_format: Literal["content", "content_and_artifact"] = "content"
@@ -175,40 +115,20 @@ class TodoList(BaseTool):
def _run(self, steps: list[str] | None = None, done_step: int = -1) -> str:
try:
steps = steps or []
# 从全局 store 读取当前会话 + 当前 agent 的状态(由 runtime 层在调用前设置 session/agent)
from agent.history.tool_call_recorder import get_current_session
sid = get_current_session() or ""
current = get_todo_store_data(sid) or {"steps": [], "done_steps": []}
stored_steps = list(current.get("steps") or [])
done_steps = list(current.get("done_steps") or [])
# 如果传入了新的 steps,更新存储
if steps:
stored_steps = steps
done_steps = []
# 标记步骤完成
if stored_steps and 1 <= done_step <= len(stored_steps):
for i in range(done_step):
if i not in done_steps:
done_steps.append(i)
done_steps.sort()
todo_data = {"steps": stored_steps, "done_steps": done_steps}
# 实时推送到前端 todo 浮窗
if sid:
set_todo_store_data(sid, todo_data)
sid = ""
try:
from agent.history.tool_call_recorder import get_current_session
sid = get_current_session() or ""
except Exception:
pass
stored_steps, done_steps = _reconstruct_state(sid, steps, done_step)
if not stored_steps:
return json.dumps({"__todo_list__": True, "data": todo_data}, ensure_ascii=False) + "\n[Todo List]\n(当前无任务)"
return "[Todo List]\n(当前无任务)"
lines = ["[Todo List]"]
for i, s in enumerate(stored_steps):
status = "[Done]" if i in done_steps else "[Pending]"
lines.append(f"- [{i+1}/{len(stored_steps)}] {status}: {s}")
return json.dumps({"__todo_list__": True, "data": todo_data}, ensure_ascii=False) + "\n" + "\n".join(lines)
return "\n".join(lines)
except Exception as e:
return f"[ERROR] todo_list 工具执行出错: {str(e)}"
+30 -147
View File
@@ -147,48 +147,18 @@ def get_history(session_id: str) -> JsonChatMessageHistory:
# ---------- session_meta 元数据管理 ----------
def _session_meta_path(session_id: str) -> str:
return os.path.join(SESSIONS_ROOT, session_id, "session_meta.json")
def _load_session_meta(session_id: str) -> dict:
"""加载整个 session_meta 数据(优先 MySQL,回退 JSON 文件)。"""
# mysql 后端:从 sessions.extra.agent_meta 读取
try:
from agent.history import session_storage
_db_extra = session_storage.load_session_extra(session_id)
if _db_extra is not None:
return _db_extra.get("agent_meta", {})
except Exception:
pass
# json 后端:从文件读取
mp = _session_meta_path(session_id)
if not os.path.isfile(mp):
return {}
try:
with open(mp, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return {}
"""加载整个 session_meta 数据(后端无关:mysql 存 sessions.extra.agent_meta,json 存文件)。"""
from agent.history import session_storage
return session_storage.load_session_extra(session_id).get("agent_meta", {})
def _save_session_meta(session_id: str, meta: dict) -> None:
"""保存整个 session_meta 数据(优先 MySQL,回退 JSON 文件)。"""
# mysql 后端:写入 sessions.extra.agent_meta(不覆盖 tokens)
try:
from agent.history import session_storage
if session_storage.is_mysql():
existing = session_storage.load_session_extra(session_id) or {}
existing["agent_meta"] = meta
session_storage.save_session_extra(session_id, existing)
return
except Exception:
pass
# json 后端:写入文件
mp = _session_meta_path(session_id)
os.makedirs(os.path.dirname(mp), exist_ok=True)
with open(mp, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
"""保存整个 session_meta 数据(后端无关,不覆盖 extra 中的 tokens)。"""
from agent.history import session_storage
existing = session_storage.load_session_extra(session_id) or {}
existing["agent_meta"] = meta
session_storage.save_session_extra(session_id, existing)
def get_session_meta(session_id: str, agent_name: str) -> dict:
@@ -264,137 +234,50 @@ import threading as _threading
TURN_JSON_PREFIX = "turn_"
def _turn_json_path(session_id: str, turn_id: str) -> str:
"""返回指定 turn 的对话 JSON 文件路径。"""
return os.path.join(SESSIONS_ROOT, session_id, f"{TURN_JSON_PREFIX}{turn_id}.json")
def turn_files_dir(session_id: str, turn_id: str) -> str:
"""返回指定 turn 的附件目录路径。"""
return os.path.join(SESSIONS_ROOT, session_id, turn_id, "files")
def save_turn_messages(session_id: str, turn_id: str, messages: list[dict]) -> None:
"""将一轮对话的消息持久化(mysql 后端只写 DB,json 后端只写文件)。
"""将一轮对话的消息持久化(后端无关:mysql 只入库,json 只写文件,不双写)。
系统定位:
消息持久化的唯一入口(sync_turn_from_chat_history 调用)。
不双写:sql 就是 sql,json 就是 json。附件二进制文件始终落盘(由调用方处理),
本函数仅负责对话消息记录的持久化。
附件二进制文件始终落盘(由调用方处理),本函数仅负责对话消息记录的持久化。
"""
# mysql 后端:只写数据库
try:
from agent.history import session_storage
if session_storage.is_mysql():
session_storage.persist_messages(session_id, turn_id, messages)
return
except Exception as _e:
import sys as _sys
print(f"[agent_utils] save_turn_messages 后端判定/DB 写入失败: {_e}", file=_sys.stderr)
return
# json 后端:只写文件
sess_dir = os.path.join(SESSIONS_ROOT, session_id)
os.makedirs(sess_dir, exist_ok=True)
tp = _turn_json_path(session_id, turn_id)
with open(tp, "w", encoding="utf-8") as f:
json.dump({"turn_id": turn_id, "messages": messages}, f, ensure_ascii=False, indent=2)
from agent.history import session_storage
session_storage.save_turn(session_id, turn_id, messages)
def load_all_turn_messages(session_id: str) -> list[dict]:
"""按时间顺序加载所有 turn 的消息,返回扁平列表。
系统定位:
mysql 后端从数据库读取(旧文件会话未迁移,返回空);json 后端从文件读取。
"""
# mysql 后端:从数据库读取
try:
from agent.history import session_storage
_db_msgs = session_storage.load_messages(session_id)
if _db_msgs is not None:
return _db_msgs
except Exception as _e:
import sys as _sys
print(f"[agent_utils] load_all_turn_messages DB 读取失败: {_e}", file=_sys.stderr)
# json 后端:从文件读取
sess_dir = os.path.join(SESSIONS_ROOT, session_id)
if not os.path.isdir(sess_dir):
return []
all_msgs: list[dict] = []
turn_files = sorted(
[f for f in os.listdir(sess_dir) if f.startswith(TURN_JSON_PREFIX) and f.endswith(".json")]
)
for tf in turn_files:
tp = os.path.join(sess_dir, tf)
if os.path.isfile(tp):
try:
with open(tp, "r", encoding="utf-8") as f:
data = json.load(f)
all_msgs.extend(data.get("messages", []))
except (json.JSONDecodeError, OSError):
pass
return all_msgs
"""按时间顺序加载所有 turn 的消息,返回扁平列表(后端无关)。"""
from agent.history import session_storage
return session_storage.load_messages(session_id)
def delete_turns_after(session_id: str, keep_until_turn_id: str) -> int:
"""删除指定 turn_id 之后(按文件名排序)的所有 turn JSON 文件及对应目录。返回删除数量。
"""删除指定 turn_id 之后(按文件名排序)的所有 turn 记录并清理对应 artifact 目录。
注意:keep_until_turn_id 自身的 turn 文件会被保留(只删除严格大于它的)。
记录删除(消息 + 工具调用)由统一存储处理后端无关;artifact 目录(以 turn_id
命名的目录,两个后端均落盘)在此清理。返回删除的 turn 数。
注意:keep_until_turn_id 自身的 turn 记录会被保留(只删除严格大于它的)。
"""
sess_dir = os.path.join(SESSIONS_ROOT, session_id)
if not os.path.isdir(sess_dir):
return 0
deleted = 0
# 必须带上 ".json" 后缀进行比较,否则 keep_until_turn_id 自身的文件
# (如 "turn_20260727_113953.json") 会因字符串扩展而 > "turn_20260727_113953",
# 导致保留点 turn 被误删。
keep_full = f"{TURN_JSON_PREFIX}{keep_until_turn_id}.json"
for fname in sorted(os.listdir(sess_dir)):
if fname.startswith(TURN_JSON_PREFIX) and fname.endswith(".json"):
if fname > keep_full:
tp = os.path.join(sess_dir, fname)
if os.path.isfile(tp):
os.remove(tp)
deleted += 1
# 同时删除对应的 turn 目录(artifact 目录)
for dname in sorted(os.listdir(sess_dir)):
dp = os.path.join(sess_dir, dname)
if os.path.isdir(dp) and dname > keep_until_turn_id:
shutil.rmtree(dp, ignore_errors=True)
# mysql 后端:同步删除数据库中 keep_until_turn_id 之后的记录
try:
from agent.history import session_storage
session_storage.delete_turns_after(session_id, keep_until_turn_id)
except Exception as _e:
import sys as _sys
print(f"[agent_utils] delete_turns_after DB 同步失败: {_e}", file=_sys.stderr)
return deleted
if os.path.isdir(sess_dir):
for dname in sorted(os.listdir(sess_dir)):
dp = os.path.join(sess_dir, dname)
if os.path.isdir(dp) and dname > keep_until_turn_id:
shutil.rmtree(dp, ignore_errors=True)
from agent.history import session_storage
return session_storage.delete_turns_after(session_id, keep_until_turn_id)
def get_last_turn_id(session_id: str) -> str | None:
"""获取最后一个 turn_id。
系统定位:
mysql 后端从 sessions.last_turn_id 读取;json 后端从文件名读取。
"""
# mysql 后端:从数据库读取
try:
from agent.history import session_storage
_db_tid = session_storage.get_last_turn_id(session_id)
if _db_tid is not None:
return _db_tid or None
except Exception as _e:
import sys as _sys
print(f"[agent_utils] get_last_turn_id DB 读取失败: {_e}", file=_sys.stderr)
# json 后端:从文件名读取
sess_dir = os.path.join(SESSIONS_ROOT, session_id)
if not os.path.isdir(sess_dir):
return None
turn_files = sorted(
[f for f in os.listdir(sess_dir) if f.startswith(TURN_JSON_PREFIX) and f.endswith(".json")]
)
if not turn_files:
return None
return turn_files[-1][len(TURN_JSON_PREFIX):-5] # strip "turn_" prefix and ".json" suffix
"""获取最后一个 turn_id(后端无关);无记录返回 None。"""
from agent.history import session_storage
return session_storage.get_last_turn_id(session_id) or None
def session_dir(session_id: str) -> str:
+64 -7
View File
@@ -1,8 +1,8 @@
"""工具调用规范化、工具执行与 JSON 安全转换。
系统定位:
位于 Agent 工具链与人工确认(HITL)之间的适配层,被 ``core/routing``、
``core/runtime``、``core/approvals``、``tools/agent_call`` 调用,
位于 Agent 工具链与工具执行之间的适配层,被 ``core/runtime``、
``tools/agent_call`` 调用,
负责:归一化工具调用对象、安全执行工具并构造 ToolMessage、JSON 安全转换。
可扩展性:
@@ -21,7 +21,7 @@ def _invoke_with_timeout(tool, tool_call_input: dict, timeout: float) -> Any:
"""在 daemon 线程中执行 tool.invoke,超时返回 None。
超时后工作线程仍在后台运行(Python 无法强杀线程),结果被丢弃。
工具内部抛出的异常会在主线程 re-raise(保留 SubAgentPendingError 冒泡)。
工具内部抛出的异常会在主线程 re-raise(保留 ABORTED_BY_USER 冒泡)。
输入:
tool: LangChain BaseTool 实例。
@@ -36,7 +36,7 @@ def _invoke_with_timeout(tool, tool_call_input: dict, timeout: float) -> Any:
def _worker():
try:
container["result"] = tool.invoke(tool_call_input)
except BaseException as e: # noqa: BLE001 - 需透传 SubAgentPendingError 等所有异常
except BaseException as e: # noqa: BLE001 - 需透传 ABORTED_BY_USER 等所有异常
container["error"] = e
t = threading.Thread(target=_worker, daemon=True)
@@ -68,7 +68,10 @@ def invoke_tool_and_build_message(tool, tool_name: str, tool_args: dict, tool_ca
系统定位:
由 ``core/runtime`` 和 ``tools/agent_call`` 在需要手动 invoke 工具时调用。
同时是工具执行前的唯一钳点:工作空间访问策略(限制访问 / 无人值守审批
退化)在此拦截越界操作,覆盖图内执行、人工确认恢复循环与审批后重放路径。
退化)在此拦截越界操作;权限为 confirm 的工具与审批模式下的越界操作
在此阻塞征求用户意见(拒绝/跳过也仅是返回值,图继续执行)。
human_interaction 为阻塞工具,直连调用绕过超时包装,其内部
ask_human 的 ABORTED_BY_USER 直接冒泡到 runtime。
"""
from langchain_core.messages import ToolMessage
@@ -81,6 +84,55 @@ def invoke_tool_and_build_message(tool, tool_name: str, tool_args: dict, tool_ca
except Exception:
pass # 策略模块异常不应阻断工具执行
# 人工确认钳点:confirm 权限工具 / 审批模式越界操作,先阻塞征求用户同意
if tool_name != "human_interaction":
try:
from agent.core.tool_policy import classify_tool_execution
from agent.core.workspace_policy import check_violation as _policy_check_violation
from agent.core.workspace_policy import decision as _policy_decision
from agent.core.human_request import ask_human, get_current_agent_name
from agent.history.tool_call_recorder import get_current_session
_need_ask = classify_tool_execution(tool_name, tool_args) == "confirm"
_policy_note = ""
if _policy_decision(tool_name, tool_args) == "approve":
_need_ask = True
_policy_note = _policy_check_violation(tool_name, tool_args) or "检测到超出工作空间范围的操作"
if _need_ask:
_agent = get_current_agent_name()
_agent_name = "主Agent" if _agent in ("", "__main__") else _agent
_safe_args = json_safe(tool_args)
_meta = {
"type": "tool_call",
"title": f"待确认执行工具:{tool_name}",
"tool_name": tool_name,
"args": {"name": tool_name, "args": _safe_args},
"instruction": json.dumps({"name": tool_name, "args": _safe_args}, ensure_ascii=False, indent=2),
"options": ["approve", "reject", "skip"],
"policy_note": _policy_note,
"agent_name": _agent_name,
"is_sub_agent": _agent_name != "主Agent",
"resolved": False,
}
_sid = get_current_session() or ""
_answer = ask_human(_sid, _meta)
_decision = _answer.get("decision")
if _decision in ("reject", "skip"):
_verb = "拒绝" if _decision == "reject" else "已跳过"
_hint = (_answer.get("instruction") or "").strip()
_text = f"用户{_verb}此工具调用({tool_name})"
if _hint:
_text += f":{_hint}"
return _text, None
if _decision == "timeout":
return f"等待用户确认超时,未执行工具({tool_name})。", None
# approve(或 edit):继续执行
except RuntimeError:
raise # ABORTED_BY_USER 冒泡
except Exception:
pass # 确认通道异常不应阻断工具执行
tool_call_input = {
"name": tool_name,
"args": tool_args,
@@ -94,7 +146,12 @@ def invoke_tool_and_build_message(tool, tool_name: str, tool_args: dict, tool_ca
timeout = get_tool_timeout(tool_name) or DEFAULT_TOOL_TIMEOUT
except Exception:
timeout = 300.0
result = _invoke_with_timeout(tool, tool_call_input, timeout)
if tool_name == "human_interaction":
# 人机交互是阻塞工具:等待可能很长,绕过超时包装直连调用,
# 其内部 ask_human 的 ABORTED_BY_USER 直接冒泡到 runtime 按暂停处理
result = tool.invoke(tool_call_input)
else:
result = _invoke_with_timeout(tool, tool_call_input, timeout)
if result is None:
return f"工具执行超时({int(timeout)}s)", None
if isinstance(result, ToolMessage):
@@ -122,7 +179,7 @@ def json_safe(value: Any) -> Any:
JSON 兼容的 dict / list / str / 基本类型;结构与原值对应。
系统定位:
供 ``core/approvals.build_approval_meta`` 在展示待确认工具参数时使用,
供 ``core/runtime`` 与 ``tools/agent_call`` 在展示待确认工具参数时使用,
避免 UI 层因不可序列化对象而报错。
可扩展性:
+7 -16
View File
@@ -12,18 +12,15 @@ import { setRenderAttachmentChips as audioSetRenderChips } from './js/audio.js';
// ---- 聊天渲染 ----
import {
renderMessages, renderMessage, renderAttachmentChips, scrollChatToBottom,
appendThinkingIndicator, showPendingActionInThinking, streamAssistantMessage,
appendThinkingIndicator, streamAssistantMessage,
renderAudioBubble, addTextQuote, initChatQuoteMenu,
} from './js/chat-render.js';
import { setRenderPersistentToolCalls } from './js/chat-render.js';
// ---- 工具调用 / think ----
import { injectLiveToolCalls } from './js/toolcalls.js';
import { injectLiveToolCalls, setLiveUiDeps } from './js/toolcalls.js';
import { setRenderAudioBubble as toolcallsSetAudioBubble } from './js/toolcalls.js';
// ---- 人工确认 ----
import { setRenderFnDeps as pendingSetDeps } from './js/pending.js';
// ---- 人工确认浮窗 ----
import { updatePendingOverlay, clearPendingOverlay } from './js/pending-overlay.js';
@@ -36,7 +33,7 @@ import { sendChat, handlePauseClick } from './js/send.js';
import { setSendDeps } from './js/send.js';
// ---- Todo ----
import { updateTodoOverlay, clearTodoOverlay } from './js/todo.js';
import { updateTodoOverlayFromRecords, clearTodoOverlay } from './js/todo.js';
// ---- 布局 / 背景 ----
import {
@@ -93,14 +90,10 @@ import { renderPersistentToolCalls } from './js/toolcalls.js';
setRenderPersistentToolCalls(renderPersistentToolCalls);
toolcallsSetAudioBubble(renderAudioBubble);
// pending 需要 sessions/chat 的函数
pendingSetDeps({
renderSessions,
appendThinkingIndicator,
renderMessages,
streamAssistantMessage,
injectLiveToolCalls,
scrollChatToBottom,
// live snapshot 驱动人工确认浮窗与 Todo 浮窗(toolcalls 收到快照后回调)
setLiveUiDeps({
updatePendingOverlay,
updateTodoOverlayFromRecords,
});
// sessions 需要 renderMessages, renderAttachmentChips, clearTodoOverlay
@@ -117,9 +110,7 @@ setSendDeps({
renderAttachmentChips,
appendThinkingIndicator,
injectLiveToolCalls,
updateTodoOverlay,
clearTodoOverlay,
showPendingActionInThinking,
streamAssistantMessage,
onSendStart: () => setComposerCentered(false),
});
+22 -4
View File
@@ -990,7 +990,28 @@
background: var(--divider);
}
/* ===== Skill 导入菜单 ===== */
/* ===== Skill 导入按钮与菜单 ===== */
.settings-toolbar-buttons {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 导入按钮的定位锚点:下拉菜单相对该包裹元素定位 */
.skill-import-wrap {
position: relative;
display: inline-flex;
}
/* 导入按钮与"创建 Skill"同风格(btn-primary),仅配色略有不同 */
.import-skill-btn {
background: linear-gradient(165deg, #64748b, #475569);
box-shadow: 0 4px 14px rgba(71, 85, 105, 0.35);
}
.import-skill-btn:hover:not(:disabled) {
background: linear-gradient(165deg, #94a3b8, #64748b);
}
.skill-import-menu {
position: absolute;
z-index: 90;
@@ -1020,6 +1041,3 @@
.skill-import-menu button:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.settings-toolbar {
position: relative;
}
+6 -2
View File
@@ -389,8 +389,12 @@
<!-- Skills 管理面板 -->
<div class="settings-panel" data-panel="skills" id="skillsPanel" hidden>
<div class="settings-toolbar">
<button type="button" class="btn btn-primary" id="addSkillBtn">+ 创建 Skill</button>
<button type="button" class="btn btn-ghost" id="importSkillBtn" title="导入 zip 或文件夹(自动解析 skill.md)">导入 Skill</button>
<div class="settings-toolbar-buttons">
<button type="button" class="btn btn-primary" id="addSkillBtn">+ 创建 Skill</button>
<span class="skill-import-wrap">
<button type="button" class="btn btn-primary import-skill-btn" id="importSkillBtn" title="导入 zip 或文件夹(自动解析 skill.md)">导入 Skill</button>
</span>
</div>
</div>
<input type="file" id="importSkillZipInput" accept=".zip" hidden />
<input type="file" id="importSkillFolderInput" webkitdirectory multiple hidden />
+16 -86
View File
@@ -1,103 +1,33 @@
// pending.js -- 待确认决策处理(卡片渲染已迁移至 pending-overlay.js)
// pending.js -- 人工请求决策提交(卡片渲染在 pending-overlay.js,数据源为 live snapshot)
import { $ } from './utils.js';
import { api } from './api.js';
import { startPollLiveToolCalls } from './toolcalls.js';
import { state } from './state.js';
import { updatePendingOverlay } from './pending-overlay.js';
let renderSessionsFn = null;
let appendThinkingIndicatorFn = null;
let renderMessagesFn = null;
let streamAssistantMessageFn = null;
let injectLiveToolCallsFn = null;
let scrollChatToBottomFn = null;
export function setRenderFnDeps(deps) {
renderSessionsFn = deps.renderSessions;
appendThinkingIndicatorFn = deps.appendThinkingIndicator;
renderMessagesFn = deps.renderMessages;
streamAssistantMessageFn = deps.streamAssistantMessage;
injectLiveToolCallsFn = deps.injectLiveToolCalls;
scrollChatToBottomFn = deps.scrollChatToBottom;
}
async function handleHumanAction(approvalId, decision, instruction, card) {
if (!approvalId || state.sending) return;
state.sending = true;
/**
* 提交一个等待中的人工请求的决策。
*
* 阻塞式交互模型下,本函数只负责把答案交给后端:
* 后端写入等待注册表并唤醒阻塞中的工具,图在后台线程自然继续;
* 浮窗由下一次 live snapshot 自动移除,最终结果由
* /api/chat/complete 或 /api/chat/stream 返回——无需重渲染消息、
* 无需重启 live 流、更不会清空已积累的工具调用列表。
*/
async function handleHumanAction(requestId, decision, instruction, card) {
if (!requestId || state.sending) return;
state.sending = true; // 本轮仍在后台运行,保持发送按钮禁用
const sendBtn = $("sendBtn");
if (sendBtn) sendBtn.disabled = true;
if (card) card.classList.add("pending-action-card--resolved");
// 复用 sendChat 留下的现有 thinking 气泡(不删除不重建)
const chatMessages = $("chatMessages");
let typingEl = chatMessages ? chatMessages.querySelector(".msg-typing") : null;
if (!typingEl) {
// 兜底:如果没有现有气泡(如页面刷新后),创建一个新的
typingEl = appendThinkingIndicatorFn ? appendThinkingIndicatorFn() : null;
}
// 将 pending 工具调用标记为完成,带上用户输入的结果
if (typingEl && injectLiveToolCallsFn) {
const resultText = instruction
|| (decision === "reject" ? "用户拒绝" : decision === "skip" ? "用户已跳过" : "用户已确认");
injectLiveToolCallsFn(typingEl, [{
name: "human_interaction",
status: "done",
result_text: resultText,
}]);
}
const fd = new FormData();
fd.append("session_id", state.sessionId);
fd.append("approval_id", approvalId);
fd.append("approval_id", requestId);
fd.append("decision", decision);
fd.append("instruction", instruction || "");
fd.append("output_type", state.outputMode || "text");
const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
try {
const done = await api("/api/human-action", { method: "POST", body: fd });
stopToolPoll();
// Update state
state.sessionId = done.sessionId;
state.sessions.length = 0;
state.sessions.push(...(done.sessions || []));
if (renderSessionsFn) renderSessionsFn();
const messages = done.messages || [];
const last = messages[messages.length - 1];
const hasNewPending = done.pending_actions && done.pending_actions.length > 0;
if (last && last.role === "assistant") {
// 最终回复:移除 thinking 气泡,渲染 assistant
if (typingEl && typingEl.parentNode) typingEl.remove();
typingEl = null;
if (renderMessagesFn) renderMessagesFn(messages.slice(0, -1));
if (streamAssistantMessageFn) await streamAssistantMessageFn(last);
} else if (hasNewPending) {
// 又产生了 pending:保留 thinking 气泡,更新浮窗
// typingEl 不删除,继续用于下一次交互
} else {
if (typingEl && typingEl.parentNode) typingEl.remove();
typingEl = null;
if (renderMessagesFn) renderMessagesFn(messages);
}
updatePendingOverlay(done.pending_actions);
state._pendingHumanAction = hasNewPending;
} finally {
if (typingEl && typingEl.parentNode && !state._pendingHumanAction) {
typingEl.remove();
}
state.sending = false;
if (!state._pendingHumanAction) {
if (sendBtn) {
sendBtn.disabled = false;
sendBtn.innerHTML = '<img src="/image/发送.svg" alt="" aria-hidden="true" class="send-btn-icon" />';
sendBtn.classList.remove("btn-pause");
}
}
}
await api("/api/human-action", { method: "POST", body: fd });
// 注意:不恢复 state.sending——本轮任务尚未结束,按钮状态由 sendChat 完成时统一恢复
}
export {
+16 -72
View File
@@ -5,7 +5,7 @@ import { showConfirm } from './dialog.js';
import { api, streamApi } from './api.js';
import { startPollLiveToolCalls } from './toolcalls.js';
import { state } from './state.js';
import { updatePendingOverlay, clearPendingOverlay } from './pending-overlay.js';
import { clearPendingOverlay } from './pending-overlay.js';
import { renderVoiceTranscribingPlaceholder } from './chat-render.js';
let renderSessionsFn = null;
@@ -13,9 +13,7 @@ let renderMessagesFn = null;
let renderAttachmentChipsFn = null;
let appendThinkingIndicatorFn = null;
let injectLiveToolCallsFn = null;
let updateTodoOverlayFn = null;
let clearTodoOverlayFn = null;
let showPendingActionInThinkingFn = null;
let streamAssistantMessageFn = null;
let onSendStartFn = null;
let _audioAbort = null; // 音频流的独立 AbortController,供打断用
@@ -83,9 +81,7 @@ export function setSendDeps(deps) {
renderAttachmentChipsFn = deps.renderAttachmentChips;
appendThinkingIndicatorFn = deps.appendThinkingIndicator;
injectLiveToolCallsFn = deps.injectLiveToolCalls;
updateTodoOverlayFn = deps.updateTodoOverlay;
clearTodoOverlayFn = deps.clearTodoOverlay;
showPendingActionInThinkingFn = deps.showPendingActionInThinking;
streamAssistantMessageFn = deps.streamAssistantMessage;
onSendStartFn = deps.onSendStart;
}
@@ -304,24 +300,9 @@ async function sendChat() {
if (clearTodoOverlayFn) clearTodoOverlayFn();
clearPendingOverlay();
// 启动实时工具调用与反思轮询
// 启动实时工具调用与反思轮询(snapshot 同时驱动人工请求浮窗与 Todo 浮窗)
const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
// 启动 todo_list 实时轮询
let todoPollActive = true;
let todoPollTimer = null;
const pollTodoList = async () => {
if (!todoPollActive) return;
try {
const todoData = await api(`/api/todo-list/${encodeURIComponent(state.sessionId)}`);
if (todoData && todoData.has_todo && todoData.data && updateTodoOverlayFn) {
updateTodoOverlayFn(todoData.data);
}
} catch { /* ignore */ }
if (todoPollActive) todoPollTimer = setTimeout(pollTodoList, 2000);
};
pollTodoList();
const fd2 = new FormData();
fd2.append("session_id", state.sessionId);
fd2.append("output_type", state.outputMode || "text");
@@ -340,7 +321,6 @@ async function sendChat() {
let textRendered = false;
let streamSessionId = sessionIdAtStart;
let streamSessions = [];
let streamPending = [];
let nextPlayTime = 0;
let sampleRate = 24000;
let ctx = null;
@@ -360,7 +340,6 @@ async function sendChat() {
textRendered = true;
streamSessionId = event.sessionId || sessionIdAtStart;
streamSessions = event.sessions || [];
streamPending = event.pending_actions || [];
const msgs = event.messages || [];
const last = msgs[msgs.length - 1];
@@ -387,14 +366,8 @@ async function sendChat() {
state.sessions.length = 0;
state.sessions.push(...streamSessions);
if (renderSessionsFn) renderSessionsFn();
updatePendingOverlay(streamPending);
if (streamPending && streamPending.length > 0) {
state._pendingHumanAction = true;
}
stopToolPoll();
todoPollActive = false;
if (todoPollTimer) clearTimeout(todoPollTimer);
if (typingEl) _cleanupTypingEl(typingEl);
typingEl = null;
}
@@ -430,25 +403,16 @@ async function sendChat() {
} else {
// ====== 普通文字模式 ======
// 阻塞等待轮次完成(期间人机交互浮窗与工具列表由 live snapshot 驱动);
// 响应返回即本轮已结束,不存在待确认项
const done = await api("/api/chat/complete", { method: "POST", body: fd2, signal: state.abortController.signal });
if (state.sessionId !== sessionIdAtStart) {
stopToolPoll();
todoPollActive = false;
if (todoPollTimer) clearTimeout(todoPollTimer);
return;
}
try {
const todoData = await api(`/api/todo-list/${encodeURIComponent(state.sessionId)}`);
if (todoData && todoData.has_todo && todoData.data && updateTodoOverlayFn) {
updateTodoOverlayFn(todoData.data);
}
} catch { /* ignore */ }
stopToolPoll();
todoPollActive = false;
if (todoPollTimer) clearTimeout(todoPollTimer);
state.sessionId = done.sessionId;
state.sessions.length = 0;
@@ -457,30 +421,16 @@ async function sendChat() {
const messages = done.messages || [];
const last = messages[messages.length - 1];
const hasPending = done.pending_actions && done.pending_actions.length > 0;
if (last && last.role === "assistant" && last.meta && last.meta.pending_action && typingEl) {
if (showPendingActionInThinkingFn) showPendingActionInThinkingFn(typingEl, last);
updatePendingOverlay(done.pending_actions);
typingEl = null;
} else if (hasPending && typingEl) {
state._pendingHumanAction = true;
// typingEl 中已有的 live 工具调用记录保持不变(由轮询积累),
// 不调用 injectLiveToolCallsFn 以避免清除已展示的工具调用
updatePendingOverlay(done.pending_actions);
if (typingEl) _cleanupTypingEl(typingEl);
typingEl = null;
if (last && last.role === "assistant") {
const head = messages.slice(0, -1);
if (renderMessagesFn) renderMessagesFn(head);
revokeBlobUrlStack(blobUrls);
if (streamAssistantMessageFn) await streamAssistantMessageFn(last);
} else {
if (typingEl) _cleanupTypingEl(typingEl);
typingEl = null;
if (last && last.role === "assistant") {
const head = messages.slice(0, -1);
if (renderMessagesFn) renderMessagesFn(head);
revokeBlobUrlStack(blobUrls);
if (streamAssistantMessageFn) await streamAssistantMessageFn(last);
} else {
if (renderMessagesFn) renderMessagesFn(messages);
revokeBlobUrlStack(blobUrls);
}
updatePendingOverlay(done.pending_actions);
if (renderMessagesFn) renderMessagesFn(messages);
revokeBlobUrlStack(blobUrls);
}
}
} catch (e) {
@@ -532,15 +482,9 @@ async function sendChat() {
if (nsb2) { nsb2.disabled = false; nsb2.style.pointerEvents = ""; nsb2.style.opacity = ""; }
const sl2 = $("sessionList");
if (sl2) sl2.classList.remove("session-locked");
if (state._pendingHumanAction) {
// 保持"暂停"文字和样式,但不禁用按钮
const pendBtn = sendBtn || $("sendBtn");
if (pendBtn) pendBtn.disabled = false;
} else {
setSendBtnPauseMode(false);
const fBtn = sendBtn || $("sendBtn");
if (fBtn) fBtn.disabled = false;
}
setSendBtnPauseMode(false);
const fBtn = sendBtn || $("sendBtn");
if (fBtn) fBtn.disabled = false;
// 延迟检测文件变更(等待文件监听器处理完)
setTimeout(() => {
try { import('./edit-mode.js').then(m => m.detectTaskFileChanges()); } catch {}
+2 -5
View File
@@ -4,7 +4,7 @@ import { $ } from './utils.js';
import { showConfirm, showPrompt } from './dialog.js';
import { api } from './api.js';
import { state } from './state.js';
import { updatePendingOverlay, clearPendingOverlay } from './pending-overlay.js';
import { clearPendingOverlay } from './pending-overlay.js';
import { refreshTokens } from './token-ring.js';
let renderSessionsFnRef = null;
@@ -137,7 +137,6 @@ async function deleteSessionItem(id) {
if (clearTodoOverlayFn) clearTodoOverlayFn();
clearPendingOverlay();
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
updatePendingOverlay(data.pending_actions || null);
}
state.sessions.length = 0;
state.sessions.push(...(data.sessions || []).filter((sid) => sid !== deletingSessionId));
@@ -165,7 +164,6 @@ export async function bootstrap() {
state.currentTokens = data.tokens || 0;
if (renderSessionsFnRef) renderSessionsFnRef();
if (renderMessagesFn) renderMessagesFn(data.messages);
updatePendingOverlay(data.pending_actions || null);
// 设置居中状态
const msgs = data.messages || [];
import('../app.js').then((m) => m.setComposerCentered(msgs.length === 0));
@@ -179,7 +177,7 @@ async function selectSession(id) {
}
/**
* 应用一次会话数据(sessionId / sessions / messages / tokens / pending_actions)并刷新界面。
* 应用一次会话数据(sessionId / sessions / messages / tokens)并刷新界面。
* 供「分支」等创建新会话后直接跳转使用。
*/
export async function applySessionData(data) {
@@ -192,7 +190,6 @@ export async function applySessionData(data) {
clearPendingOverlay();
if (renderSessionsFnRef) renderSessionsFnRef();
if (renderMessagesFn) renderMessagesFn(data.messages || []);
updatePendingOverlay(data.pending_actions || null);
refreshTokens();
// 设置居中状态
const msgs = data.messages || [];
+30 -1
View File
@@ -106,4 +106,33 @@ function clearTodoOverlay() {
document.querySelectorAll(".todo-list-overlay").forEach((el) => el.hidden = true);
}
export { buildTodoListOverlay, updateTodoOverlay, clearTodoOverlay };
/**
* 从 live 工具调用记录派生 Todo 浮窗数据(展示类工具的通用前端 adapter)。
* todo_list 每次调用都会出现在 live snapshot 的 tool_calls 中:
* - 携带 steps 的调用 → 重建步骤列表(done 状态重置)
* - 携带 done_step 的调用 → 标记 1~n 步为完成
* 后端不再维护 todo store、也没有轮询接口。
* @param {Array} toolCalls - live snapshot 的 tool_calls 数组
*/
function updateTodoOverlayFromRecords(toolCalls) {
if (!toolCalls || !toolCalls.length) return;
let steps = null;
const doneSteps = new Set();
for (const call of toolCalls) {
if (!call || call.name !== "todo_list") continue;
const args = (call.args || {});
if (Array.isArray(args.steps) && args.steps.length) {
steps = args.steps.map((s) => String(s));
doneSteps.clear();
}
if (!steps) continue;
const n = parseInt(args.done_step, 10);
if (Number.isFinite(n) && n >= 1) {
for (let i = 0; i < Math.min(n, steps.length); i++) doneSteps.add(i);
}
}
if (!steps) return;
updateTodoOverlay({ steps, done_steps: [...doneSteps].sort((a, b) => a - b) });
}
export { buildTodoListOverlay, updateTodoOverlay, updateTodoOverlayFromRecords, clearTodoOverlay };
+64 -16
View File
@@ -890,6 +890,24 @@ function renderPersistentToolCalls(toolCalls, options = {}) {
}
/* ===== Live 工具调用增量更新(运行中) ===== */
/** 判断两行工具调用渲染是否完全相同(名称/运行状态/参数/详情内容)。 */
function _sameToolCallRow(oldRow, newRow) {
const status = (r) => (r.classList.contains("tool-call-row--running") ? "running" : "done");
const text = (r, sel) => {
const el = r.querySelector(sel);
return el ? el.textContent : "";
};
const detail = (r) => {
const next = r.nextElementSibling;
return next && next.classList && next.classList.contains("tc-detail") ? next.textContent : "";
};
return status(oldRow) === status(newRow)
&& text(oldRow, ".tc-name") === text(newRow, ".tc-name")
&& text(oldRow, ".tc-args-text") === text(newRow, ".tc-args-text")
&& detail(oldRow) === detail(newRow);
}
function injectLiveToolCalls(typingEl, liveCalls, turnStartedAt, _reflections) {
if (!typingEl || !liveCalls.length) return;
const bubble = typingEl.querySelector(".msg-bubble") || typingEl;
@@ -898,30 +916,43 @@ function injectLiveToolCalls(typingEl, liveCalls, turnStartedAt, _reflections) {
const dots = bubble.querySelector(".typing-dots");
if (dots) dots.remove();
const oldWrap = bubble.querySelector(".tool-call-list-wrap");
if (!oldWrap) {
const wrap = renderPersistentToolCalls(liveCalls, { live: true, reflections: _reflections, turnStartedAt: turnStartedAt });
if (!wrap) return;
wrap.classList.add("tool-call-list-wrap--expanded");
bubble.appendChild(wrap);
return;
}
// 保存展开状态:记录哪些 tool-call-row 和 think-row 是展开的
let expandedRows = new Set();
let collapsedThinkRows = new Set();
const oldWrap = bubble.querySelector(".tool-call-list-wrap");
if (oldWrap) {
oldWrap.querySelectorAll(".tool-call-row--expanded").forEach((r) => {
const idx = Array.from(oldWrap.querySelectorAll(".tool-call-row")).indexOf(r);
if (idx >= 0) expandedRows.add(idx);
});
oldWrap.querySelectorAll(".think-row").forEach((r, i) => {
if (!r.classList.contains("think-row--expanded")) collapsedThinkRows.add(i);
});
if (oldWrap._stopTimer) oldWrap._stopTimer();
}
oldWrap.querySelectorAll(".tool-call-row--expanded").forEach((r) => {
const idx = Array.from(oldWrap.querySelectorAll(".tool-call-row")).indexOf(r);
if (idx >= 0) expandedRows.add(idx);
});
oldWrap.querySelectorAll(".think-row").forEach((r, i) => {
if (!r.classList.contains("think-row--expanded")) collapsedThinkRows.add(i);
});
if (oldWrap._stopTimer) oldWrap._stopTimer();
// 将 reflections 传给 renderPersistentToolCalls
const newWrap = renderPersistentToolCalls(liveCalls, { live: true, reflections: _reflections, turnStartedAt: turnStartedAt });
if (!newWrap) return;
if (oldWrap) {
oldWrap.replaceWith(newWrap);
} else {
bubble.appendChild(newWrap);
}
// 行级 diff:未变化的行复用旧 DOM(保持阅读位置与展开态),
// 仅更新状态变化的行并追加新行,避免整个列表每次快照都重建
const oldRows = Array.from(oldWrap.querySelectorAll(".tool-call-row"));
const newRows = Array.from(newWrap.querySelectorAll(".tool-call-row"));
newRows.forEach((newRow, i) => {
const oldRow = oldRows[i];
if (oldRow && _sameToolCallRow(oldRow, newRow)) {
newRow.replaceWith(oldRow);
}
});
oldWrap.replaceWith(newWrap);
// 恢复展开状态
if (expandedRows.size > 0 || collapsedThinkRows.size > 0) {
@@ -1058,6 +1089,14 @@ function toolCallImageUrls(call) {
}
/* ===== SSE 触发式 live 工具调用(替代轮询) ===== */
// 人工确认浮窗 / Todo 浮窗由本模块的 snapshot 通道驱动(app.js 注入)
let liveUiDeps = { updatePendingOverlay: null, updateTodoOverlayFromRecords: null };
export function setLiveUiDeps(deps) {
liveUiDeps = { ...liveUiDeps, ...(deps || {}) };
}
function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
let active = true;
let lastReflectionCount = 0;
@@ -1071,6 +1110,14 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
if (liveData.started_at && !turnStartedAt) {
turnStartedAt = Number(liveData.started_at);
}
// 人工请求浮窗:等待中的 human_requests 出现即渲染,应答后随快照消失
if (liveUiDeps.updatePendingOverlay && Array.isArray(liveData.human_requests)) {
liveUiDeps.updatePendingOverlay(liveData.human_requests);
}
// Todo 浮窗:从 tool_calls 记录派生(展示类工具的通用 adapter)
if (liveUiDeps.updateTodoOverlayFromRecords && Array.isArray(liveData.tool_calls)) {
liveUiDeps.updateTodoOverlayFromRecords(liveData.tool_calls);
}
// 收集思考内容
if (liveData.reflections && liveData.reflections.length > lastReflectionCount) {
const newOnes = liveData.reflections.slice(lastReflectionCount);
@@ -1149,6 +1196,7 @@ export {
renderUserFileCard,
attachHorizontalScroll,
startPollLiveToolCalls,
setLiveUiDeps,
showImagePreview,
showFilePreviewDialog,
formatDuration,
+135 -171
View File
@@ -29,7 +29,7 @@ _SRC = Path(__file__).resolve().parent.parent.parent
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
from agent.agents import agent_runtime as ca # noqa: E402
from agent.core import turn_runner # noqa: E402
from web import ui_session as ui # noqa: E402
from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS # noqa: E402
from web.ui_session import _AUDIO_EXTENSIONS # noqa: E402
@@ -448,8 +448,7 @@ def api_bootstrap() -> dict[str, Any]:
输入: 无。
输出:
{"sessionId": str, "sessions": list[str], "messages": list[dict], "tokens": int,
"pending_actions": list[dict]}
{"sessionId": str, "sessions": list[str], "messages": list[dict], "tokens": int}
系统定位:
前端页面加载时调用的第一个 API,用于初始化界面。
@@ -464,7 +463,6 @@ def api_bootstrap() -> dict[str, Any]:
"sessions": ids,
"messages": _serialize_messages(hist, sid),
"tokens": get_session_tokens(sid),
"pending_actions": _get_pending_actions(sid),
}
@@ -483,7 +481,7 @@ def api_new_session() -> dict[str, Any]:
sid = ui.new_timestamp_session_id()
ids = ui.list_session_ids_ordered()
ids = [sid] + [x for x in ids if x != sid]
return {"sessionId": sid, "sessions": ids, "messages": [], "tokens": 0, "pending_actions": []}
return {"sessionId": sid, "sessions": ids, "messages": [], "tokens": 0}
def _branch_copy_attachment(abs_src: str, dst_dir: str, new_sid: str) -> str:
@@ -510,57 +508,47 @@ def _branch_copy_attachment(abs_src: str, dst_dir: str, new_sid: str) -> str:
return dest
def _branch_rewrite_attachments(dst_dir: str, new_sid: str) -> None:
"""将分支复制后的 turn 文件中的附件引用复制到新会话目录并重写 relpath。
def _branch_rewrite_attachments(msgs: list[dict], dst_dir: str, new_sid: str) -> None:
"""将分支消息中的附件引用复制到新会话目录并重写 relpath,使新会话自包含。
输入:
msgs: 分支消息列表(user 条目 relpath / assistant 条目 audio_relpath)。
dst_dir: 新会话目录。
new_sid: 新会话 ID。
"""
from agent.utils.agent_utils import SESSIONS_ROOT, TURN_JSON_PREFIX
new_sid: 新会话 ID(用于冲突重命名)。
for name in os.listdir(dst_dir):
if not (name.startswith(TURN_JSON_PREFIX) and name.endswith(".json")):
continue
tp = os.path.join(dst_dir, name)
try:
with open(tp, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
continue
changed = False
for msg in data.get("messages", []):
role = msg.get("role")
if role == "user":
content = (msg.get("user") or {}).get("content") or []
for piece in content:
if not isinstance(piece, dict):
continue
rel = piece.get("relpath")
if not rel:
continue
abs_src = os.path.normpath(os.path.join(SESSIONS_ROOT, rel))
if not os.path.isfile(abs_src):
continue
系统定位:
附件二进制文件始终落盘(两个后端一致),此处仅处理消息内容中的
附件引用重写,与存储后端无关。
"""
from agent.utils.agent_utils import SESSIONS_ROOT
for msg in msgs:
role = msg.get("role")
if role == "user":
content = (msg.get("user") or {}).get("content") or []
for piece in content:
if not isinstance(piece, dict):
continue
rel = piece.get("relpath")
if not rel:
continue
abs_src = os.path.normpath(os.path.join(SESSIONS_ROOT, rel))
if not os.path.isfile(abs_src):
continue
dest = _branch_copy_attachment(abs_src, dst_dir, new_sid)
new_rel = os.path.relpath(dest, SESSIONS_ROOT).replace("\\", "/")
if new_rel != rel:
piece["relpath"] = new_rel
elif role == "assistant":
asst = msg.get("assistant") or {}
audio_rel = asst.get("audio_relpath")
if audio_rel:
abs_src = os.path.normpath(os.path.join(SESSIONS_ROOT, audio_rel.replace("/", os.sep)))
if os.path.isfile(abs_src):
dest = _branch_copy_attachment(abs_src, dst_dir, new_sid)
new_rel = os.path.relpath(dest, SESSIONS_ROOT).replace("\\", "/")
if new_rel != rel:
piece["relpath"] = new_rel
changed = True
elif role == "assistant":
asst = msg.get("assistant") or {}
audio_rel = asst.get("audio_relpath")
if audio_rel:
abs_src = os.path.normpath(os.path.join(SESSIONS_ROOT, audio_rel.replace("/", os.sep)))
if os.path.isfile(abs_src):
dest = _branch_copy_attachment(abs_src, dst_dir, new_sid)
new_rel = os.path.relpath(dest, SESSIONS_ROOT).replace("\\", "/")
if new_rel != audio_rel:
asst["audio_relpath"] = new_rel
changed = True
if changed:
with open(tp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
if new_rel != audio_rel:
asst["audio_relpath"] = new_rel
@app.post("/api/sessions/branch")
@@ -575,49 +563,42 @@ def api_session_branch(
branch_turn_id: 分支点回合 ID(该回合及其之前的历史会被复制到新会话)。
输出:
{"sessionId": str, "sessions": list[str], "messages": list[dict], "tokens": int,
"pending_actions": list[dict]}
{"sessionId": str, "sessions": list[str], "messages": list[dict], "tokens": int}
系统定位:
AI 回复的「分支」按钮:复制一份历史到新会话,用户可在此基础上继续对话。
基于统一存储 API 实现(消息/工具记录读写均后端无关,mysql 后端同样支持)。
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
# mysql 后端不落 turn/tool 文件,暂不支持文件级分支
try:
from agent.history import session_storage
if session_storage.is_mysql():
raise HTTPException(status_code=400, detail="mysql 存储后端暂不支持分支")
except Exception:
pass
from agent.utils.agent_utils import SESSIONS_ROOT
from agent.history import session_storage
from agent.utils.agent_utils import SESSIONS_ROOT, TURN_JSON_PREFIX
# 源会话 turn 列表(后端无关)
turn_ids = session_storage.list_turn_ids(session_id)
if not turn_ids:
raise HTTPException(status_code=400, detail="源会话不存在或为空")
branch_ids = [t for t in turn_ids if (not branch_turn_id) or t <= branch_turn_id]
if not branch_ids:
raise HTTPException(status_code=400, detail="无效的分支回合")
src_dir = os.path.join(SESSIONS_ROOT, session_id)
if not os.path.isdir(src_dir):
raise HTTPException(status_code=400, detail="源会话不存在")
# 创建新会话并复制 branch_turn_id 及之前的 turn/tool 文件
# 创建新会话并复制 branch_turn_id 及之前的消息与工具调用记录
new_sid = ui.new_timestamp_session_id()
dst_dir = os.path.join(SESSIONS_ROOT, new_sid)
os.makedirs(dst_dir, exist_ok=True)
branch_turn_full = f"{TURN_JSON_PREFIX}{branch_turn_id}.json"
tool_branch_full = f"tool_{branch_turn_id}.json"
copied_turns = 0
try:
for name in sorted(os.listdir(src_dir)):
src_path = os.path.join(src_dir, name)
if name.startswith(TURN_JSON_PREFIX) and name.endswith(".json") and name <= branch_turn_full:
shutil.copy2(src_path, os.path.join(dst_dir, name))
copied_turns += 1
elif name.startswith("tool_") and name.endswith(".json") and name <= tool_branch_full:
shutil.copy2(src_path, os.path.join(dst_dir, name))
if copied_turns == 0:
raise ValueError("分支点回合不存在")
# 复制附件引用并重写 relpath,使新会话自包含
_branch_rewrite_attachments(dst_dir, new_sid)
for tid in branch_ids:
msgs = session_storage.load_turn_messages(session_id, tid) or []
# 复制附件引用并重写 relpath,使新会话自包含
_branch_rewrite_attachments(msgs, dst_dir, new_sid)
session_storage.save_turn(new_sid, tid, msgs)
rec = session_storage.get_turn_record(session_id, tid)
if rec:
_meta = {k: rec[k] for k in ("started_at", "ended_at", "duration", "aborted") if k in rec}
session_storage.save_tool_calls(
new_sid, tid, rec.get("tool_calls") or [], meta=_meta or None
)
except Exception:
shutil.rmtree(dst_dir, ignore_errors=True)
raise HTTPException(status_code=400, detail="无效的分支回合")
@@ -629,7 +610,6 @@ def api_session_branch(
"sessions": ids,
"messages": _serialize_messages(ui.load_ui_messages_for_session(new_sid), new_sid),
"tokens": get_session_tokens(new_sid),
"pending_actions": _get_pending_actions(new_sid),
}
@@ -656,7 +636,6 @@ def api_session_messages(session_id: str) -> dict[str, Any]:
"sessions": ids,
"messages": _serialize_messages(hist, session_id),
"tokens": get_session_tokens(session_id),
"pending_actions": _get_pending_actions(session_id),
}
@@ -700,19 +679,20 @@ def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
# 先设置 abort 标志,通知正在执行的 Agent 停止
# 先设置 abort 标志并应答等待中的人工请求,通知正在执行的 Agent 停止
try:
from agent.core.runtime import set_abort_flag
set_abort_flag(session_id)
except Exception:
pass
ui.delete_session(session_id)
# delete_session 已清理 tool_calling 目录,此处仅需清理 todo_store 和 token
try:
from agent.tools.todo_list import clear_todo_store_session
clear_todo_store_session(session_id)
from agent.core.human_request import resolve_all_for_session
resolve_all_for_session(session_id, decision="reject", instruction="会话已删除")
except Exception:
pass
ui.delete_session(session_id)
turn_runner.drop_turn(session_id)
# delete_session 已清理 tool_calling 目录,此处仅需清理 token
try:
from agent.history.tool_call_recorder import clear_session_tokens
clear_session_tokens(session_id)
@@ -725,7 +705,6 @@ def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
"sessions": ids,
"messages": _serialize_messages(hist, sid),
"tokens": get_session_tokens(sid),
"pending_actions": _get_pending_actions(sid),
}
@@ -860,16 +839,9 @@ def _chat_start_core(session_id: str, query: dict[str, Any]) -> dict[str, Any]:
"sessionId": session_id,
"sessions": ids,
"messages": _serialize_messages(new_hist, session_id),
"pending_actions": _get_pending_actions(session_id),
}
def _get_pending_actions(session_id: str) -> list[dict[str, Any]]:
"""从 PENDING_TOOL_APPROVALS 获取当前会话的待处理 human_interaction 列表。"""
from agent.core.approvals import get_pending_actions_for_session
return get_pending_actions_for_session(session_id)
def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode: str = "agent") -> dict[str, Any]:
"""对 turn 文件中末条 user 执行视觉解析与 ``chain_with_history.invoke``。
@@ -883,6 +855,8 @@ def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode:
系统定位:
/api/chat/complete 与 /api/chat(兼容模式)的后端核心逻辑。
图在后台线程(turn_runner)中执行,本函数阻塞等待其完成;
期间人机交互浮窗与工具调用列表由 live snapshot 通道推送。
可扩展性:
可支持异步执行、流式输出。
@@ -894,14 +868,17 @@ def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode:
detail="没有待完成的用户消息,请先调用 /api/chat/start",
)
normalized_mode = agent_mode if agent_mode in {"agent", "plan"} else "agent"
new_hist = ca.execute_agent([*hist], session_id, agent_mode=normalized_mode)
turn_runner.start_turn(session_id, [*hist], normalized_mode)
result = turn_runner.wait_turn(session_id)
if result is None or "error" in result:
raise HTTPException(status_code=500, detail="Agent 执行失败")
new_hist = result["chat_history"]
ui.prune_memory_session_ids()
ids = _ensure_session_in_list(session_id, ui.list_session_ids_ordered())
return {
"sessionId": session_id,
"sessions": ids,
"messages": _serialize_messages(new_hist, session_id),
"pending_actions": _get_pending_actions(session_id),
}
@@ -975,20 +952,24 @@ def api_chat_complete(
def _stream_chat_complete_core(session_id: str, output_type: str = "text", agent_mode: str = "agent"):
"""SSE 流式推理:先执行 Agent,然后对最终回复做流式 TTS。"""
"""SSE 流式推理:后台线程执行 Agent,完成后对最终回复做流式 TTS。"""
import time as _time
# 执行 Agent(blocking)
# 启动后台线程执行 Agent(阻塞等待;期间 live snapshot 推送工具调用与人机交互浮窗)
hist = ui.load_ui_messages_for_session(session_id)
if not hist or hist[-1].get("role") != "user":
yield f"data: {json.dumps({'type': 'error', 'message': '没有待完成的用户消息'}, ensure_ascii=False)}\n\n"
return
normalized_mode = agent_mode if agent_mode in {"agent", "plan"} else "agent"
new_hist = ca.execute_agent([*hist], session_id, agent_mode=normalized_mode)
turn_runner.start_turn(session_id, [*hist], normalized_mode)
result = turn_runner.wait_turn(session_id)
if result is None or "error" in result:
yield f"data: {json.dumps({'type': 'error', 'message': 'Agent 执行失败'}, ensure_ascii=False)}\n\n"
return
new_hist = result["chat_history"]
ui.prune_memory_session_ids()
ids = _ensure_session_in_list(session_id, ui.list_session_ids_ordered())
pending = _get_pending_actions(session_id)
# 提取最终 assistant 消息文本
final_text = ""
@@ -1000,8 +981,8 @@ def _stream_chat_complete_core(session_id: str, output_type: str = "text", agent
final_text = "".join(p.get("text", "") for p in final_text if isinstance(p, dict) and p.get("type") == "text")
break
# 先发送文本消息(含完整的 messages、sessions、pending)
yield f"data: {json.dumps({'type': 'text', 'text': str(final_text), 'messages': messages, 'sessionId': session_id, 'sessions': ids, 'pending_actions': pending}, ensure_ascii=False)}\n\n"
# 先发送文本消息(含完整的 messages、sessions)
yield f"data: {json.dumps({'type': 'text', 'text': str(final_text), 'messages': messages, 'sessionId': session_id, 'sessions': ids}, ensure_ascii=False)}\n\n"
# 语音模式:流式 TTS
if output_type == "voice" and final_text.strip():
@@ -1081,6 +1062,13 @@ def api_chat_abort(session_id: str = Form(...)) -> dict[str, Any]:
set_abort_flag(session_id)
except Exception:
pass
# 应答等待中的人工请求:阻塞工具醒来后检测到 abort 标志,抛 ABORTED_BY_USER
# 由 runtime 统一按暂停处理(先置标志再应答,保证暂停优先于答案)
try:
from agent.core.human_request import resolve_all_for_session
resolve_all_for_session(session_id, decision="reject", instruction="已被用户手动暂停")
except Exception:
pass
# 不 rollback,保留当前轮已完成的工具调用记录,追加暂停消息结束本轮
hist = ui.load_ui_messages_for_session(session_id)
paused = list(hist or [])
@@ -1160,35 +1148,31 @@ def api_human_action(
decision: str = Form(...),
instruction: str = Form(""),
) -> dict[str, Any]:
"""用户对待确认操作(HITL)做出决策后,继续执行 Agent。
"""应答一个等待中的人工请求(人机交互/工具确认)。
输入:
session_id: 会话 ID。
approval_id: 待确认项的 ID。
decision: approve | reject | edit | skip(skip 不执行工具但继续图)。
approval_id: 等待中请求的 ID(由 live snapshot 的 human_requests 提供)。
decision: approve | reject | edit | skip(skip 表示跳过工具调用但继续图)。
instruction: 用户输入的补充文本(用于 edit 或 selection)。
output_type: 回复的输出类型。
输出:
{"sessionId": str, "sessions": list[str], "messages": list[dict]}
{"sessionId": str, "status": "answered"}
系统定位:
前端确认框提交决策时调用的回调接口。
前端浮窗提交决策时调用的回调接口。答案写入等待注册表并唤醒阻塞中的
工具,图在后台线程自然继续;最终结果由 /api/chat/complete 或
/api/chat/stream 返回,本接口不做任何图执行。
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
if decision not in {"approve", "reject", "edit", "skip"}:
raise HTTPException(status_code=400, detail="不支持的人工处理结果")
hist = ui.load_ui_messages_for_session(session_id)
new_hist = ca.continue_after_human_action(
[*hist],
session_id=session_id,
approval_id=approval_id,
decision=decision,
instruction=instruction,
)
ids = _ensure_session_in_list(session_id, ui.list_session_ids_ordered())
return {"sessionId": session_id, "sessions": ids, "messages": _serialize_messages(new_hist, session_id), "pending_actions": _get_pending_actions(session_id)}
from agent.core.human_request import answer_human_request
ok = answer_human_request(session_id, approval_id, decision, instruction)
if not ok:
raise HTTPException(status_code=404, detail="该待确认项已处理或已过期")
return {"sessionId": session_id, "status": "answered"}
# ---------- 配置管理 API ----------
@@ -1687,16 +1671,18 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
# ---------- 工具调用历史 API ----------
@app.get("/api/tool-calls/{session_id}/live")
def api_get_live_tool_calls(session_id: str) -> dict[str, Any]:
"""获取当前进行中的工具调用与反思内容(前端执行期间实时轮询)。"""
"""获取当前进行中的工具调用、反思内容与等待中的人工请求(前端执行期间实时轮询)。"""
try:
from agent.history.tool_call_recorder import get_live_tool_calls, get_live_reflections, get_turn_started_at
from agent.core.human_request import get_human_requests_for_session
return {
"tool_calls": get_live_tool_calls(session_id),
"reflections": get_live_reflections(session_id),
"started_at": get_turn_started_at(session_id),
"human_requests": get_human_requests_for_session(session_id),
}
except Exception as e:
return {"tool_calls": [], "reflections": [], "error": str(e)}
return {"tool_calls": [], "reflections": [], "human_requests": [], "error": str(e)}
@app.get("/api/tool-calls/{session_id}/stream")
@@ -1704,12 +1690,13 @@ async def api_stream_live_tool_calls(session_id: str, request: Request):
"""SSE 流式推送实时工具调用更新(触发式,替代轮询)。
每当后端 record_tool_call_live / record_tool_result_live / record_reflection_live
被调用时,通过 pub/sub queue 立即推送快照给前端。
或人工请求注册/应答被调用时,通过 pub/sub queue 立即推送快照给前端。
"""
from agent.history.tool_call_recorder import (
subscribe_live, unsubscribe_live,
get_live_tool_calls, get_live_reflections, get_turn_started_at,
)
from agent.core.human_request import get_human_requests_for_session
q = subscribe_live(session_id)
@@ -1720,6 +1707,7 @@ async def api_stream_live_tool_calls(session_id: str, request: Request):
"tool_calls": get_live_tool_calls(session_id),
"reflections": get_live_reflections(session_id),
"started_at": get_turn_started_at(session_id),
"human_requests": get_human_requests_for_session(session_id),
}
yield f"event: update\ndata: {json.dumps(initial, ensure_ascii=False)}\n\n"
@@ -1797,22 +1785,6 @@ def api_get_tool_file(path: str, session: str = "") -> FileResponse:
)
# ---------- Todo List 实时推送 ----------
# 数据直接从 agent.tools.tool 的共享 store 读取,无需额外存储
@app.get("/api/todo-list/{session_id}")
def api_get_todo_list(session_id: str) -> dict[str, Any]:
"""获取当前会话最新的 todo list 数据(前端轮询)。"""
try:
from agent.tools.todo_list import get_todo_store_data
data = get_todo_store_data(session_id)
except Exception:
data = None
if data:
return {"has_todo": True, "data": data}
return {"has_todo": False, "data": None}
# ---------- Skill 管理 API ----------
@app.get("/api/skills")
def api_list_skills() -> dict[str, Any]:
@@ -1971,16 +1943,20 @@ def api_delete_skill_attachment(name: str, filename: str) -> dict[str, Any]:
def _clear_tool_calling(session_id: str, keep_turn_ids: set[str] | None = None) -> None:
"""清理会话关联的工具调用记录与 todo_list 缓存。
"""清理会话关联的工具调用记录。
输入:
session_id: 会话 ID。
keep_turn_ids: 需要保留的 turn_id 集合(回滚时保留未回滚部分的工具调用记录)。
为 None 时删除全部 tool_*.json。
为 None 时删除全部工具调用记录。
系统定位:
工具调用记录删除由统一存储处理后端无关(mysql 删表 / json 删 tool_*.json);
artifact 目录(以 turn_id 命名的目录,两个后端均落盘)在此清理。
"""
try:
from agent.tools.todo_list import clear_todo_store_session
clear_todo_store_session(session_id)
from agent.history import session_storage
session_storage.delete_tool_records(session_id, keep_turn_ids)
except Exception:
pass
try:
@@ -1989,16 +1965,10 @@ def _clear_tool_calling(session_id: str, keep_turn_ids: set[str] | None = None)
sd = _Path(session_tool_dir(session_id))
if sd.is_dir():
import shutil as _shutil
# 只删除工具调用 JSON 和 artifact 目录,不删 turn JSON(对话内容)和 session_meta
for f in sd.glob("tool_*.json"):
if keep_turn_ids:
turn_id = f.stem.replace("tool_", "")
if turn_id in keep_turn_ids:
continue
f.unlink(missing_ok=True)
# 只删除 artifact 目录(以 turn_id 命名,不含 turn_ 前缀的目录),
# 不删 turn JSON(对话内容)和 session_meta
for sub in sd.iterdir():
if sub.is_dir():
# 删除 artifact 目录(以 turn_id 命名,不含 turn_ 前缀的目录)
if not sub.name.startswith("turn_"):
_shutil.rmtree(str(sub), ignore_errors=True)
except Exception:
@@ -2177,8 +2147,8 @@ def index_page() -> FileResponse:
def _serialize_cron_messages(task_id: str) -> list[dict[str, Any]]:
"""序列化定时任务的执行记录消息,结构与 _serialize_messages 一致。
复用 _serialize_content 处理附件 data URL,但工具调用记录从
JsonCronRecordRepository 读取(cron 任务存于 history/cron/{task_id}/,
复用 _serialize_content 处理附件 data URL,工具调用记录从统一存储的
cron 作用域记录仓库读取(cron 任务存于 history/cron/{task_id}/,
与主进程 tool_call_recorder.TOOL_CALLING_ROOT 指向的 sessions 目录隔离)。
"""
from agent.cron.storage import get_record_repository
@@ -2194,33 +2164,27 @@ def _serialize_cron_messages(task_id: str) -> list[dict[str, Any]]:
serialized_content = _serialize_content(content, meta=meta, role=role)
out.append({"role": role, "content": serialized_content, "meta": meta})
# 为每条带 turn_id 的 assistant 消息挂载工具调用记录
# 为每条带 turn_id 的 assistant 消息挂载工具调用记录(统一存储,后端无关)
for m in out:
if m.get("role") != "assistant":
continue
turn_id = (m.get("meta") or {}).get("turn_id", "")
if not turn_id:
continue
tool_calls = record_repo.get_tool_calls(task_id, turn_id)
rec = record_repo.get_turn_record(task_id, turn_id)
if not rec:
continue
tool_calls = rec.get("tool_calls") or []
m_meta = m.setdefault("meta", {})
if tool_calls:
m_meta = m.setdefault("meta", {})
m_meta["tool_calls"] = tool_calls
# 附加 turn 元信息(started_at/ended_at/duration)
try:
from agent.cron.storage import CRON_ROOT
import json as _json
tp = os.path.join(CRON_ROOT, task_id, f"tool_{turn_id}.json")
if os.path.isfile(tp):
with open(tp, "r", encoding="utf-8") as f:
rec = _json.load(f)
turn_meta = {
k: rec.get(k) for k in ("started_at", "ended_at", "duration")
if rec.get(k) is not None
}
if turn_meta:
m_meta["turn_meta"] = turn_meta
except Exception:
pass
# 附加 turn 元信息(started_at/ended_at/duration)
turn_meta = {
k: rec.get(k) for k in ("started_at", "ended_at", "duration")
if rec.get(k) is not None
}
if turn_meta:
m_meta["turn_meta"] = turn_meta
return out
+11 -45
View File
@@ -20,7 +20,6 @@ from agent.utils.agent_utils import (
get_last_turn_id,
turn_files_dir,
session_dir as _agent_session_dir,
TURN_JSON_PREFIX,
set_pending_turn_id,
)
from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS
@@ -35,44 +34,14 @@ def session_dir(session_id: str) -> str:
def list_session_ids_ordered() -> list[str]:
"""从 sessions 目录按最近修改时间倒序列出所有 session_id。
会话发现规则:目录下存在 turn_*.json 文件。
"""从存储按最近活动倒序列出所有 session_id(后端无关)。
系统定位:
mysql 后端从 sessions 表按 updated_at 倒序读取(旧文件会话未迁移,不显示);
json 后端从文件目录扫描。
mysql 后端从 sessions 表按 updated_at 倒序读取;
json 后端从目录扫描(存在 turn_*.json 的目录按 mtime 倒序)。
"""
# mysql 后端:从数据库读取
try:
from agent.history import session_storage
_db_ids = session_storage.list_session_ids()
if _db_ids is not None:
return _db_ids
except Exception as _e:
import sys as _sys
print(f"[ui_session] list_session_ids_ordered DB 读取失败: {_e}", file=_sys.stderr)
# json 后端:从文件目录扫描
root = SESSIONS_ROOT
if not os.path.isdir(root):
return []
entries = []
for name in os.listdir(root):
dir_path = os.path.join(root, name)
if not os.path.isdir(dir_path):
continue
# 检查是否存在 turn_*.json 文件
try:
has_turns = any(
f.startswith(TURN_JSON_PREFIX) and f.endswith(".json")
for f in os.listdir(dir_path)
)
except OSError:
has_turns = False
if has_turns:
mtime = os.path.getmtime(dir_path)
entries.append((mtime, name))
entries.sort(key=lambda x: x[0], reverse=True)
return [name for _, name in entries]
from agent.history import session_storage
return session_storage.list_session_ids()
def new_timestamp_session_id() -> str:
@@ -117,7 +86,7 @@ def prune_memory_session_ids() -> None:
def delete_session(session_id: str) -> None:
"""删除会话的内存缓存、turn 文件与附件目录。
"""删除会话的内存缓存、目录(附件等)与存储记录。
输入:
session_id: 要删除的会话 ID。
@@ -125,7 +94,8 @@ def delete_session(session_id: str) -> None:
输出: 无。
系统定位:
API 层删除会话时彻底清理所有相关数据。
API 层删除会话时彻底清理所有相关数据(目录清理与后端无关,
记录删除由统一存储处理后端无关)。
"""
if not session_id:
return
@@ -136,13 +106,9 @@ def delete_session(session_id: str) -> None:
shutil.rmtree(str(session_tool_dir(session_id)), ignore_errors=True)
except Exception:
pass
# mysql 后端:同步删除数据库中该会话的全部记录(json 后端 no-op)
try:
from agent.history import session_storage
session_storage.delete_session(session_id)
except Exception as _e:
import sys as _sys
print(f"[ui_session] delete_session DB 同步失败: {_e}", file=_sys.stderr)
# 存储记录删除(后端无关:mysql 删三表,json 删会话目录/记录文件)
from agent.history import session_storage
session_storage.delete_session(session_id)
def _build_user_chat_content(query) -> str | list | None: