重构交互类和展示类工具流程
This commit is contained in:
1 parent
9acb38c9e6
commit
2a0875452f
33 files changed
+2645
-3534
No files matched your search
@@ -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> — 敏感操作(终端执行、文件写入等)弹出确认浮窗,用户可同意、拒绝或修改指令 | <b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表</sub>
|
||||
<sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)与信息收集以浮窗形式实时弹出,用户可同意、拒绝、跳过或补充信息;**拒绝也只是一次工具返回值**,Agent 自行调整方案继续,不会结束本轮 | <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 桌面端等),完成大部分的开发工作。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,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: 工具执行安全策略
|
||||
|
||||
系统定位:
|
||||
|
||||
@@ -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 "")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user