From 0608161c82c056488a62866142c079e30b1a859f Mon Sep 17 00:00:00 2001 From: renjianbo <18691577328@163.com> Date: Thu, 9 Apr 2026 21:58:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E5=A4=9A=E7=BA=BF=E8=B7=AF=E7=94=B1=E4=B8=8E?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐平台模板与场景 DSL、预算控制、执行看板和企业场景脚本,增强 Windows 启动/迁移与前端代理和聊天会话记忆,修复执行创建阶段 500 与异步链路排障体验。 Made-with: Cursor --- Windows启动指南.md | 58 +- agent记忆实现方案.md | 1 + .../006_add_execution_parent_depth.py | 49 ++ .../versions/007_add_execution_pause_state.py | 39 ++ .../versions/008_add_agent_budget_config.py | 30 + backend/app/api/agents.py | 37 +- backend/app/api/execution_logs.py | 144 ++++- backend/app/api/executions.py | 220 +++++-- backend/app/api/platform_templates.py | 72 +++ backend/app/core/config.py | 12 + backend/app/core/database.py | 6 +- backend/app/core/error_handler.py | 41 +- backend/app/core/exceptions.py | 8 + backend/app/main.py | 3 +- backend/app/models/agent.py | 5 + backend/app/models/execution.py | 11 +- backend/app/services/execution_budget.py | 45 ++ backend/app/services/llm_service.py | 11 +- backend/app/services/scenario_dsl.py | 39 ++ backend/app/services/scene_templates.py | 171 ++++++ backend/app/services/workflow_engine.py | 428 +++++++++++-- backend/app/services/workflow_validator.py | 2 +- backend/app/tasks/workflow_tasks.py | 261 +++++++- backend/scripts/bootstrap_scene_templates.py | 98 +++ .../create_complex_enterprise_agent.py | 320 ++++++++++ .../create_enterprise_scenario_agents.py | 562 ++++++++++++++++++ backend/scripts/create_main_agent.py | 141 +++++ .../create_router_invoke_demo_agent.py | 167 ++++++ backend/scripts/create_zhini_kefu_15.py | 302 ++++++++++ backend/scripts/create_zhini_kefu_16.py | 552 +++++++++++++++++ .../scripts/e2e_enterprise_multilane_agent.py | 239 ++++++++ .../scripts/e2e_platform_capability_smoke.py | 184 ++++++ ...2e_platform_capability_smoke_testclient.py | 92 +++ backend/scripts/e2e_router_invoke_demo.py | 111 ++++ backend/scripts/e2e_subworkflow_chain.py | 181 ++++++ backend/scripts/e2e_zhini16_hello.py | 124 ++++ backend/scripts/restart_api_worker.ps1 | 51 +- .../templates/template_customer_service.py | 57 ++ .../scripts/templates/template_dev_codegen.py | 54 ++ .../templates/template_ops_log_analysis.py | 54 ++ frontend/src/api/index.ts | 35 +- frontend/src/components/AgentChatPreview.vue | 21 +- frontend/src/components/MainLayout.vue | 12 +- frontend/src/router/index.ts | 12 + frontend/src/stores/agent.ts | 31 + frontend/src/stores/execution.ts | 3 + frontend/src/views/Agents.vue | 127 ++++ frontend/src/views/ExecutionBoard.vue | 140 +++++ frontend/src/views/ExecutionDetail.vue | 65 +- frontend/src/views/Executions.vue | 7 +- frontend/src/views/MainConsole.vue | 169 ++++++ frontend/vite.config.ts | 4 +- multi_route_workflow.json | 196 ++++++ 上传git仓.md | 12 +- 可执行的 90 天演进路线图.md | 245 ++++++++ 知你客服15号_循环工作流设计.md | 142 +++++ 知你客服15号能力文档.md | 34 ++ 知你客服16号能力文档.md | 39 ++ 58 files changed, 6104 insertions(+), 172 deletions(-) create mode 100644 backend/alembic/versions/006_add_execution_parent_depth.py create mode 100644 backend/alembic/versions/007_add_execution_pause_state.py create mode 100644 backend/alembic/versions/008_add_agent_budget_config.py create mode 100644 backend/app/api/platform_templates.py create mode 100644 backend/app/services/execution_budget.py create mode 100644 backend/app/services/scenario_dsl.py create mode 100644 backend/app/services/scene_templates.py create mode 100644 backend/scripts/bootstrap_scene_templates.py create mode 100644 backend/scripts/create_complex_enterprise_agent.py create mode 100644 backend/scripts/create_enterprise_scenario_agents.py create mode 100644 backend/scripts/create_main_agent.py create mode 100644 backend/scripts/create_router_invoke_demo_agent.py create mode 100644 backend/scripts/create_zhini_kefu_15.py create mode 100644 backend/scripts/create_zhini_kefu_16.py create mode 100644 backend/scripts/e2e_enterprise_multilane_agent.py create mode 100644 backend/scripts/e2e_platform_capability_smoke.py create mode 100644 backend/scripts/e2e_platform_capability_smoke_testclient.py create mode 100644 backend/scripts/e2e_router_invoke_demo.py create mode 100644 backend/scripts/e2e_subworkflow_chain.py create mode 100644 backend/scripts/e2e_zhini16_hello.py create mode 100644 backend/scripts/templates/template_customer_service.py create mode 100644 backend/scripts/templates/template_dev_codegen.py create mode 100644 backend/scripts/templates/template_ops_log_analysis.py create mode 100644 frontend/src/views/ExecutionBoard.vue create mode 100644 frontend/src/views/MainConsole.vue create mode 100644 multi_route_workflow.json create mode 100644 可执行的 90 天演进路线图.md create mode 100644 知你客服15号_循环工作流设计.md create mode 100644 知你客服15号能力文档.md create mode 100644 知你客服16号能力文档.md diff --git a/Windows启动指南.md b/Windows启动指南.md index 89df55f..d9c29d1 100644 --- a/Windows启动指南.md +++ b/Windows启动指南.md @@ -9,7 +9,7 @@ - ✅ pnpm 10.33.0(已安装) ### 需要安装的软件 -- ❌ Redis(需要安装) +- ❌ Redis(需要安装,但可以选择便携版) ## 步骤 1:安装 Redis(选择一种方式) @@ -32,7 +32,35 @@ ``` 应该能看到 Redis 容器正在运行。 -### 选项 B:安装 Redis for Windows +### 选项 B:使用 Redis 便携版(快速启动,无需安装) + +1. **下载 Redis Windows 便携版** + ```bash + cd "D:/aaa/aiagent/backend" + curl -L -o redis.zip "https://github.com/microsoftarchive/redis/releases/download/win-3.2.100/Redis-x64-3.2.100.zip" + ``` + +2. **解压 Redis** + ```bash + # Windows PowerShell + Expand-Archive -Path redis.zip -DestinationPath redis + # 或使用解压工具解压 + ``` + +3. **启动 Redis 服务器** + ```bash + cd redis + ./redis-server.exe redis.windows.conf + ``` + Redis 将在端口 6379 启动。 + +4. **验证 Redis 是否运行** + ```bash + ./redis-cli.exe ping + ``` + 应该返回:`PONG` + +### 选项 C:安装 Redis for Windows(作为服务) 1. **下载 Redis Windows 版本** - 从 GitHub 下载:https://github.com/microsoftarchive/redis/releases @@ -49,7 +77,7 @@ - 找到 `port 6379` 改为 `port 6380` - 重启 Redis 服务 -### 选项 C:使用 WSL 安装 Redis +### 选项 D:使用 WSL 安装 Redis 如果你有 WSL(Windows Subsystem for Linux): @@ -103,7 +131,7 @@ REDIS_URL=redis://localhost:6380/0 # REDIS_URL=redis://localhost:6379/0 # CORS配置 -CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038 +CORS_ORIGINS=http://localhost:3001,http://127.0.0.1:3001,http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038 # DeepSeek API密钥(已有) DEEPSEEK_API_KEY=sk-fdf7cc1c73504e628ec0119b7e11b8cc @@ -152,7 +180,7 @@ pnpm install ```typescript export default defineConfig({ server: { - port: 3000, + port: 3001, proxy: { '/api': { target: 'http://localhost:8037', @@ -173,8 +201,8 @@ export default defineConfig({ pnpm dev ``` -前端服务将在 http://localhost:3000 启动。 -注意:访问地址是 http://localhost:3000,不是 8038(8038 是 Docker 映射端口)。 +前端服务将在 http://localhost:3001 启动。 +注意:访问地址是 http://localhost:3001,前端默认使用3001端口。 ## 步骤 4:验证服务 @@ -182,7 +210,7 @@ pnpm dev - **后端API**: http://localhost:8037 - **API文档**: http://localhost:8037/docs -- **前端**: http://localhost:3000 +- **前端**: http://localhost:3001 ### 2. 测试健康检查 @@ -193,7 +221,7 @@ curl http://localhost:8037/health ## 步骤 5:创建第一个工作流 -1. 访问 http://localhost:3000 +1. 访问 http://localhost:3001 2. 注册新用户或使用现有账户登录 3. 点击"创建工作流"进入可视化编辑器 4. 拖拽节点、连接、配置并保存 @@ -229,7 +257,7 @@ pymysql.err.OperationalError: (2003, "Can't connect to MySQL server...") **错误信息**: ``` -Proxy error: Could not proxy request /api/auth/me from localhost:3000 to http://localhost:8037 +Proxy error: Could not proxy request /api/auth/me from localhost:3001 to http://localhost:8037 ``` **解决方案**: @@ -247,7 +275,7 @@ Proxy error: Could not proxy request /api/auth/me from localhost:3000 to http:// ### 5. 端口被占用 **解决方案**: -- 检查端口 8037 和 3000 是否被其他程序占用 +- 检查端口 8037 和 3001 是否被其他程序占用 - 可以修改端口: - 后端:修改启动命令端口 `--port 8038` - 前端:修改 `vite.config.ts` 中的 `port` @@ -270,7 +298,7 @@ REM 启动前端服务 start cmd /k "cd /d frontend && pnpm dev" echo 服务启动完成! -echo 前端: http://localhost:3000 +echo 前端: http://localhost:3001 echo 后端API: http://localhost:8037/docs ``` @@ -289,7 +317,7 @@ Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd backend; .\ven Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd frontend; pnpm dev" Write-Host "服务启动完成!" -ForegroundColor Green -Write-Host "前端: http://localhost:3000" -ForegroundColor Yellow +Write-Host "前端: http://localhost:3001" -ForegroundColor Yellow Write-Host "后端API: http://localhost:8037/docs" -ForegroundColor Yellow ``` @@ -311,7 +339,7 @@ Write-Host "后端API: http://localhost:8037/docs" -ForegroundColor Yellow --- -**文档版本**: 1.0 -**最后更新**: 2026-04-06 +**文档版本**: 1.1 +**最后更新**: 2026-04-09 > 注意:本指南针对 Windows 本地开发环境。生产环境部署请参考 [方案-优化版.md](./方案-优化版.md)。 \ No newline at end of file diff --git a/agent记忆实现方案.md b/agent记忆实现方案.md index d89367e..8f0e583 100644 --- a/agent记忆实现方案.md +++ b/agent记忆实现方案.md @@ -67,6 +67,7 @@ - **串用户 / 丢记忆**:检查执行入参是否带 **`user_id`**、Cache **key** 是否与预期一致。 - **改代码不生效**:工作流在 **Celery Worker** 中执行,需**重启 API 与 Celery** 后再测。 - **画像为空但上一轮已告知姓名**:检查末行 JSON 是否包含 `user_profile`、是否为空对象覆盖;并确认引擎已支持「末行 JSON」解析(见 `WorkflowEngine` 中 `_parse_zhini_final_json_dict`、`_enrich_llm_json_user_profile` 等)。 +- **画布预览/聊天发送报 500「数据库操作失败」**:① 浏览器访问 **`/health`**,响应中应有 **`checks` / `builtin_tools`**(否则 8037 可能不是当前仓库 API,或存在多实例);② 在 **`backend`** 目录执行 **`alembic upgrade head`**,保证 `executions` 等表含最新列(如 `parent_execution_id`、`depth`、`pause_state`);③ 前端预览请求需带稳定 **`user_id`**(`AgentChatPreview` 已按 Agent 维度写入 `localStorage`),否则记忆键会退化为 `default`。 --- diff --git a/backend/alembic/versions/006_add_execution_parent_depth.py b/backend/alembic/versions/006_add_execution_parent_depth.py new file mode 100644 index 0000000..f935be3 --- /dev/null +++ b/backend/alembic/versions/006_add_execution_parent_depth.py @@ -0,0 +1,49 @@ +"""add execution parent/depth columns + +Revision ID: 006_add_execution_parent_depth +Revises: 005_persistent_user_memory +Create Date: 2026-04-08 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.mysql import CHAR + + +revision = "006_add_execution_parent_depth" +down_revision = "005_persistent_user_memory" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "executions", + sa.Column("parent_execution_id", CHAR(36), nullable=True, comment="父执行ID"), + ) + op.add_column( + "executions", + sa.Column("depth", sa.Integer(), nullable=False, server_default="0", comment="执行深度(根为0)"), + ) + op.create_foreign_key( + "fk_executions_parent_execution_id", + "executions", + "executions", + ["parent_execution_id"], + ["id"], + ) + op.create_index( + "ix_executions_parent_execution_id", + "executions", + ["parent_execution_id"], + unique=False, + ) + op.create_index("ix_executions_depth", "executions", ["depth"], unique=False) + op.alter_column("executions", "depth", server_default=None) + + +def downgrade() -> None: + op.drop_index("ix_executions_depth", table_name="executions") + op.drop_index("ix_executions_parent_execution_id", table_name="executions") + op.drop_constraint("fk_executions_parent_execution_id", "executions", type_="foreignkey") + op.drop_column("executions", "depth") + op.drop_column("executions", "parent_execution_id") diff --git a/backend/alembic/versions/007_add_execution_pause_state.py b/backend/alembic/versions/007_add_execution_pause_state.py new file mode 100644 index 0000000..6566fa8 --- /dev/null +++ b/backend/alembic/versions/007_add_execution_pause_state.py @@ -0,0 +1,39 @@ +"""add execution pause_state for HITL + +Revision ID: 007_add_execution_pause_state +Revises: 006_add_execution_parent_depth +Create Date: 2026-04-08 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "007_add_execution_pause_state" +down_revision = "006_add_execution_parent_depth" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "executions", + sa.Column("pause_state", sa.JSON(), nullable=True, comment="挂起快照(审批节点 HITL)"), + ) + op.alter_column( + "executions", + "status", + existing_type=sa.String(length=20), + type_=sa.String(length=32), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.drop_column("executions", "pause_state") + op.alter_column( + "executions", + "status", + existing_type=sa.String(length=32), + type_=sa.String(length=20), + existing_nullable=False, + ) diff --git a/backend/alembic/versions/008_add_agent_budget_config.py b/backend/alembic/versions/008_add_agent_budget_config.py new file mode 100644 index 0000000..cf0b253 --- /dev/null +++ b/backend/alembic/versions/008_add_agent_budget_config.py @@ -0,0 +1,30 @@ +"""add agents.budget_config JSON for per-agent budget + +Revision ID: 008_add_agent_budget_config +Revises: 007_add_execution_pause_state +Create Date: 2026-04-08 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "008_add_agent_budget_config" +down_revision = "007_add_execution_pause_state" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "agents", + sa.Column( + "budget_config", + sa.JSON(), + nullable=True, + comment="执行预算 max_steps/max_llm_invocations/max_tool_calls 等", + ), + ) + + +def downgrade() -> None: + op.drop_column("agents", "budget_config") diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 7b5987e..c8fa18b 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -3,7 +3,7 @@ Agent管理API """ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any from datetime import datetime import logging @@ -35,6 +35,7 @@ class AgentCreate(BaseModel): name: str description: Optional[str] = None workflow_config: Dict[str, Any] # 包含nodes和edges + budget_config: Optional[Dict[str, Any]] = None class AgentUpdate(BaseModel): @@ -43,6 +44,28 @@ class AgentUpdate(BaseModel): description: Optional[str] = None workflow_config: Optional[Dict[str, Any]] = None status: Optional[str] = None + budget_config: Optional[Dict[str, Any]] = None + + +class SceneTemplateItem(BaseModel): + """场景模板列表项(元数据)""" + + id: str + title: str + description: str + category: Optional[str] = None + default_temperature: Optional[float] = None + parameter_hints: List[str] = Field(default_factory=list) + + +class AgentFromSceneTemplateCreate(BaseModel): + """从场景模板创建 Agent""" + + template_id: str + name: str + description: Optional[str] = None + parameters: Dict[str, Any] = Field(default_factory=dict) + budget_config: Optional[Dict[str, Any]] = None class AgentResponse(BaseModel): @@ -51,6 +74,7 @@ class AgentResponse(BaseModel): name: str description: Optional[str] workflow_config: Dict[str, Any] + budget_config: Optional[Dict[str, Any]] = None version: int status: str user_id: Optional[str] # 允许为None @@ -125,6 +149,7 @@ async def get_agents( "name": agent.name, "description": agent.description, "workflow_config": agent.workflow_config, + "budget_config": agent.budget_config, "version": agent.version, "status": agent.status, "user_id": agent.user_id if agent.user_id else None, @@ -171,6 +196,7 @@ async def create_agent( name=agent_data.name, description=agent_data.description, workflow_config=agent_data.workflow_config, + budget_config=agent_data.budget_config, user_id=current_user.id, status="draft" ) @@ -259,6 +285,9 @@ async def update_agent( if agent_data.status not in valid_statuses: raise ValidationError(f"无效的状态: {agent_data.status}") agent.status = agent_data.status + + if agent_data.budget_config is not None: + agent.budget_config = agent_data.budget_config db.commit() db.refresh(agent) @@ -403,6 +432,9 @@ async def duplicate_agent( name=new_name, description=original_agent.description, workflow_config=new_workflow_config, + budget_config=copy.deepcopy(original_agent.budget_config) + if original_agent.budget_config is not None + else None, user_id=current_user.id, status="draft", # 复制的Agent状态为草稿 version=1 # 版本号从1开始 @@ -442,6 +474,7 @@ async def export_agent( "name": agent.name, "description": agent.description, "workflow_config": workflow_config, + "budget_config": agent.budget_config, "version": agent.version, "status": agent.status, "exported_at": datetime.utcnow().isoformat() @@ -507,6 +540,7 @@ async def import_agent( counter += 1 # 创建Agent + bc = agent_data.get("budget_config") agent = Agent( name=name, description=description, @@ -514,6 +548,7 @@ async def import_agent( "nodes": nodes, "edges": edges }, + budget_config=bc if isinstance(bc, dict) else None, user_id=current_user.id, status="draft", # 导入的Agent默认为草稿状态 version=1 diff --git a/backend/app/api/execution_logs.py b/backend/app/api/execution_logs.py index 678a11d..96a2d2a 100644 --- a/backend/app/api/execution_logs.py +++ b/backend/app/api/execution_logs.py @@ -38,6 +38,114 @@ class ExecutionLogResponse(BaseModel): from_attributes = True +def _has_execution_permission(db: Session, execution: Execution, current_user: User) -> bool: + if getattr(current_user, "role", None) == "admin": + return True + if execution.workflow_id: + workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first() + return bool(workflow and workflow.user_id == current_user.id) + if execution.agent_id: + agent = db.query(Agent).filter(Agent.id == execution.agent_id).first() + return bool(agent and (agent.user_id == current_user.id or agent.status in ["published", "running"])) + return False + + +@router.get("/executions/{execution_id}/chain") +async def get_execution_chain( + execution_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取父子执行链路(当前执行 + 递归子执行)。""" + root = db.query(Execution).filter(Execution.id == execution_id).first() + if not root: + raise NotFoundError("执行记录", execution_id) + if not _has_execution_permission(db, root, current_user): + raise NotFoundError("执行记录", execution_id) + + all_execs = db.query(Execution).all() + by_parent: Dict[Optional[str], List[Execution]] = {} + for e in all_execs: + by_parent.setdefault(e.parent_execution_id, []).append(e) + + def _build_tree(e: Execution) -> Dict[str, Any]: + children = by_parent.get(e.id, []) + children.sort(key=lambda x: x.created_at or datetime.min) + return { + "id": e.id, + "status": e.status, + "workflow_id": e.workflow_id, + "agent_id": e.agent_id, + "depth": e.depth, + "parent_execution_id": e.parent_execution_id, + "execution_time": e.execution_time, + "created_at": e.created_at.isoformat() if e.created_at else None, + "children": [_build_tree(c) for c in children], + } + + return _build_tree(root) + + +@router.get("/executions/{execution_id}/chain/summary") +async def get_execution_chain_summary( + execution_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取父子执行链路汇总(节点数、成功失败数、总耗时)。""" + root = db.query(Execution).filter(Execution.id == execution_id).first() + if not root: + raise NotFoundError("执行记录", execution_id) + if not _has_execution_permission(db, root, current_user): + raise NotFoundError("执行记录", execution_id) + + all_execs = db.query(Execution).all() + by_parent: Dict[Optional[str], List[Execution]] = {} + by_id: Dict[str, Execution] = {} + for e in all_execs: + by_parent.setdefault(e.parent_execution_id, []).append(e) + by_id[e.id] = e + + stack = [root.id] + chain_ids: List[str] = [] + while stack: + cur = stack.pop() + chain_ids.append(cur) + for c in by_parent.get(cur, []): + stack.append(c.id) + + chain_execs = [by_id[i] for i in chain_ids if i in by_id] + status_count: Dict[str, int] = { + "pending": 0, + "running": 0, + "completed": 0, + "failed": 0, + "awaiting_approval": 0, + } + total_time = 0 + for e in chain_execs: + st = (e.status or "").lower() + if st in status_count: + status_count[st] += 1 + if isinstance(e.execution_time, int): + total_time += e.execution_time + + max_depth = 0 + for e in chain_execs: + try: + max_depth = max(max_depth, int(e.depth or 0)) + except (TypeError, ValueError): + pass + + return { + "root_execution_id": root.id, + "total_executions": len(chain_execs), + "status_count": status_count, + "total_execution_time_ms": total_time, + "max_depth": max_depth, + } + + @router.get("/executions/{execution_id}", response_model=List[ExecutionLogResponse]) async def get_execution_logs( execution_id: str, @@ -58,17 +166,7 @@ async def get_execution_logs( raise NotFoundError("执行记录", execution_id) # 验证权限:检查workflow或agent的所有权 - has_permission = False - if execution.workflow_id: - workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first() - if workflow and workflow.user_id == current_user.id: - has_permission = True - elif execution.agent_id: - agent = db.query(Agent).filter(Agent.id == execution.agent_id).first() - if agent and (agent.user_id == current_user.id or agent.status in ["published", "running"]): - has_permission = True - - if not has_permission: + if not _has_execution_permission(db, execution, current_user): raise NotFoundError("执行记录", execution_id) # 构建查询 @@ -104,17 +202,7 @@ async def get_execution_log_summary( raise NotFoundError("执行记录", execution_id) # 验证权限:检查workflow或agent的所有权 - has_permission = False - if execution.workflow_id: - workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first() - if workflow and workflow.user_id == current_user.id: - has_permission = True - elif execution.agent_id: - agent = db.query(Agent).filter(Agent.id == execution.agent_id).first() - if agent and (agent.user_id == current_user.id or agent.status in ["published", "running"]): - has_permission = True - - if not has_permission: + if not _has_execution_permission(db, execution, current_user): raise NotFoundError("执行记录", execution_id) # 统计各级别日志数量 @@ -184,17 +272,7 @@ async def get_execution_performance( raise NotFoundError("执行记录", execution_id) # 验证权限:检查workflow或agent的所有权 - has_permission = False - if execution.workflow_id: - workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first() - if workflow and workflow.user_id == current_user.id: - has_permission = True - elif execution.agent_id: - agent = db.query(Agent).filter(Agent.id == execution.agent_id).first() - if agent and (agent.user_id == current_user.id or agent.status in ["published", "running"]): - has_permission = True - - if not has_permission: + if not _has_execution_permission(db, execution, current_user): raise NotFoundError("执行记录", execution_id) from sqlalchemy import func diff --git a/backend/app/api/executions.py b/backend/app/api/executions.py index 0ade4fb..2f2c3d8 100644 --- a/backend/app/api/executions.py +++ b/backend/app/api/executions.py @@ -2,9 +2,10 @@ 执行管理API """ from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import or_ from sqlalchemy.orm import Session from pydantic import BaseModel -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Literal from datetime import datetime from app.core.database import get_db from app.models.execution import Execution @@ -12,8 +13,7 @@ from app.models.workflow import Workflow from app.models.agent import Agent from app.api.auth import get_current_user from app.models.user import User -from app.services.workflow_engine import WorkflowEngine -from app.tasks.workflow_tasks import execute_workflow_task +from app.tasks.workflow_tasks import execute_workflow_task, resume_workflow_task import uuid import logging @@ -22,11 +22,56 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/executions", tags=["executions"]) +def _enqueue_workflow_task_safe( + db: Session, + execution: Execution, + workflow_label: str, + workflow_data: dict, + input_data: dict, +) -> Execution: + """ + 将工作流执行任务投递到 Celery。若 Redis/Worker 不可用,回写执行记录为 failed 并抛出 503。 + """ + try: + task = execute_workflow_task.delay( + str(execution.id), + workflow_label, + workflow_data, + input_data, + ) + execution.task_id = task.id + db.commit() + db.refresh(execution) + return execution + except Exception as e: + logger.exception( + "Celery 任务入队失败 execution_id=%s workflow_label=%s", + execution.id, + workflow_label, + ) + msg = ( + "异步任务入队失败:无法连接消息队列或 Celery Worker。" + "请确认 Redis 已启动、.env 中 REDIS_URL 正确,并已运行 Celery Worker(如 celery -A app.core.celery_app worker)。" + f" 原始错误: {e!s}" + ) + try: + execution.status = "failed" + execution.error_message = msg[:2000] + db.commit() + db.refresh(execution) + except Exception as e2: + logger.exception("回写执行失败状态时出错: %s", e2) + db.rollback() + raise HTTPException(status_code=503, detail=msg[:2000]) from e + + class ExecutionCreate(BaseModel): """执行创建模型""" workflow_id: Optional[str] = None agent_id: Optional[str] = None input_data: Dict[str, Any] + parent_execution_id: Optional[str] = None + depth: Optional[int] = 0 class ExecutionResponse(BaseModel): @@ -40,12 +85,57 @@ class ExecutionResponse(BaseModel): error_message: Optional[str] execution_time: Optional[int] task_id: Optional[str] + parent_execution_id: Optional[str] + depth: int + pause_state: Optional[Dict[str, Any]] = None created_at: datetime class Config: from_attributes = True +def _execution_to_response(ex: Execution) -> ExecutionResponse: + return ExecutionResponse( + id=str(ex.id), + workflow_id=str(ex.workflow_id) if ex.workflow_id is not None else None, + agent_id=str(ex.agent_id) if ex.agent_id is not None else None, + input_data=ex.input_data, + output_data=ex.output_data, + status=ex.status, + error_message=ex.error_message, + execution_time=ex.execution_time, + task_id=str(ex.task_id) if ex.task_id is not None else None, + parent_execution_id=( + str(ex.parent_execution_id) if ex.parent_execution_id is not None else None + ), + depth=int(ex.depth) if ex.depth is not None else 0, + pause_state=ex.pause_state, + created_at=ex.created_at, + ) + + +class ResumeExecutionBody(BaseModel): + """恢复挂起的执行(审批)""" + decision: Literal["approved", "rejected"] + comment: Optional[str] = None + + +def _can_view_execution( + db: Session, current_user: User, execution: Execution +) -> bool: + if getattr(current_user, "role", None) == "admin": + return True + if execution.workflow_id: + wf = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first() + if wf and wf.user_id == current_user.id: + return True + if execution.agent_id: + ag = db.query(Agent).filter(Agent.id == execution.agent_id).first() + if ag and ag.user_id == current_user.id: + return True + return False + + @router.post("", response_model=ExecutionResponse, status_code=status.HTTP_201_CREATED) async def create_execution( execution_data: ExecutionCreate, @@ -68,7 +158,9 @@ async def create_execution( execution = Execution( workflow_id=execution_data.workflow_id, input_data=execution_data.input_data, - status="pending" + status="pending", + parent_execution_id=execution_data.parent_execution_id, + depth=max(0, int(execution_data.depth or 0)), ) db.add(execution) db.commit() @@ -79,20 +171,15 @@ async def create_execution( 'nodes': workflow.nodes, 'edges': workflow.edges } - task = execute_workflow_task.delay( - str(execution.id), + ex = _enqueue_workflow_task_safe( + db, + execution, execution_data.workflow_id, workflow_data, - execution_data.input_data + execution_data.input_data, ) - - # 更新执行记录的task_id - execution.task_id = task.id - db.commit() - db.refresh(execution) - - return execution - + return _execution_to_response(ex) + elif execution_data.agent_id: agent = db.query(Agent).filter(Agent.id == execution_data.agent_id).first() @@ -111,7 +198,9 @@ async def create_execution( execution = Execution( agent_id=execution_data.agent_id, input_data=execution_data.input_data, - status="pending" + status="pending", + parent_execution_id=execution_data.parent_execution_id, + depth=max(0, int(execution_data.depth or 0)), ) db.add(execution) db.commit() @@ -129,20 +218,15 @@ async def create_execution( if node.get('type') == 'llm': node_data = node.get('data', {}) logger.debug(f"[rjb] LLM节点: node_id={node.get('id')}, data keys={list(node_data.keys())}, api_key={'已配置' if node_data.get('api_key') else '未配置'}") - task = execute_workflow_task.delay( - str(execution.id), - f"agent_{agent.id}", # 使用agent ID作为workflow_id标识 + ex = _enqueue_workflow_task_safe( + db, + execution, + f"agent_{agent.id}", workflow_data, - execution_data.input_data + execution_data.input_data, ) - - # 更新执行记录的task_id - execution.task_id = task.id - db.commit() - db.refresh(execution) - - return execution - + return _execution_to_response(ex) + else: raise HTTPException(status_code=400, detail="必须提供workflow_id或agent_id") @@ -152,6 +236,7 @@ async def get_executions( skip: int = 0, limit: int = 100, workflow_id: Optional[str] = None, + agent_id: Optional[str] = None, status: Optional[str] = None, search: Optional[str] = None, db: Session = Depends(get_db), @@ -164,20 +249,34 @@ async def get_executions( skip: 跳过记录数(分页) limit: 每页记录数(分页,最大100) workflow_id: 工作流ID筛选 - status: 状态筛选(pending, running, completed, failed) + status: 状态筛选(pending, running, completed, failed, awaiting_approval) search: 搜索关键词(搜索执行ID、工作流ID、任务ID) """ # 限制每页最大记录数 limit = min(limit, 100) - - # 构建基础查询:只查询当前用户的工作流/智能体的执行记录 - query = db.query(Execution).join(Workflow, Execution.workflow_id == Workflow.id).filter( - Workflow.user_id == current_user.id - ) - + + # 管理员可看全部;普通用户:自己拥有的工作流或 Agent 上的执行记录(含纯 Agent 执行) + if getattr(current_user, "role", None) == "admin": + query = db.query(Execution) + else: + query = ( + db.query(Execution) + .outerjoin(Workflow, Execution.workflow_id == Workflow.id) + .outerjoin(Agent, Execution.agent_id == Agent.id) + .filter( + or_( + Workflow.user_id == current_user.id, + Agent.user_id == current_user.id, + ) + ) + ) + # 工作流ID筛选 if workflow_id: query = query.filter(Execution.workflow_id == workflow_id) + + if agent_id: + query = query.filter(Execution.agent_id == agent_id) # 状态筛选 if status: @@ -189,12 +288,13 @@ async def get_executions( query = query.filter( (Execution.id.like(search_pattern)) | (Execution.workflow_id.like(search_pattern)) | + (Execution.agent_id.like(search_pattern)) | (Execution.task_id.like(search_pattern)) ) # 排序和分页 executions = query.order_by(Execution.created_at.desc()).offset(skip).limit(limit).all() - return executions + return [_execution_to_response(e) for e in executions] @router.get("/{execution_id}/status", response_model=Dict[str, Any]) @@ -328,6 +428,41 @@ async def get_execution_status( } +@router.post("/{execution_id}/resume", response_model=ExecutionResponse) +async def resume_execution( + execution_id: str, + body: ResumeExecutionBody, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """在审批挂起(awaiting_approval)后恢复执行。""" + execution = db.query(Execution).filter(Execution.id == execution_id).first() + if not execution: + raise HTTPException(status_code=404, detail="执行记录不存在") + if not _can_view_execution(db, current_user, execution): + raise HTTPException(status_code=403, detail="无权访问") + if execution.status != "awaiting_approval": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"当前状态不可恢复: {execution.status}", + ) + if not execution.pause_state: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="缺少挂起快照,无法恢复", + ) + + task = resume_workflow_task.delay( + str(execution.id), + body.decision, + body.comment, + ) + execution.task_id = task.id + db.commit() + db.refresh(execution) + return execution + + @router.get("/{execution_id}", response_model=ExecutionResponse) async def get_execution( execution_id: str, @@ -339,11 +474,8 @@ async def get_execution( if not execution: raise HTTPException(status_code=404, detail="执行记录不存在") - - # 验证权限 - if execution.workflow_id: - workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first() - if workflow and workflow.user_id != current_user.id: - raise HTTPException(status_code=403, detail="无权访问") - - return execution + + if not _can_view_execution(db, current_user, execution): + raise HTTPException(status_code=403, detail="无权访问") + + return _execution_to_response(execution) diff --git a/backend/app/api/platform_templates.py b/backend/app/api/platform_templates.py new file mode 100644 index 0000000..09431f6 --- /dev/null +++ b/backend/app/api/platform_templates.py @@ -0,0 +1,72 @@ +""" +场景模板 API(独立路由,避免与 /api/v1/agents/{agent_id} 在部分部署中的匹配顺序问题)。 +""" +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session +from typing import List +import logging + +from app.core.database import get_db +from app.api.auth import get_current_user +from app.models.user import User +from app.models.agent import Agent +from app.core.exceptions import ValidationError, ConflictError +from app.services.workflow_validator import validate_workflow +from app.services.scene_templates import build_workflow_for_template, list_scene_template_meta +from app.api.agents import AgentResponse, SceneTemplateItem, AgentFromSceneTemplateCreate + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/platform", tags=["platform-templates"]) + + +@router.get("/scene-templates", response_model=List[SceneTemplateItem]) +async def list_scene_templates_v1(current_user: User = Depends(get_current_user)): + _ = current_user + return list_scene_template_meta() + + +@router.post("/agents/from-template", response_model=AgentResponse, status_code=status.HTTP_201_CREATED) +async def create_agent_from_template_v1( + body: AgentFromSceneTemplateCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + workflow_config = build_workflow_for_template( + body.template_id, body.parameters or {} + ) + except ValueError as e: + raise ValidationError(str(e)) + + validation_result = validate_workflow( + workflow_config.get("nodes", []), workflow_config.get("edges", []) + ) + if not validation_result["valid"]: + raise ValidationError( + "工作流配置验证失败: " + ", ".join(validation_result["errors"]) + ) + + existing_agent = db.query(Agent).filter( + Agent.name == body.name, + Agent.user_id == current_user.id, + ).first() + if existing_agent: + raise ConflictError(f"Agent名称 '{body.name}' 已存在") + + desc = body.description or f"自场景模板 {body.template_id} 创建" + agent = Agent( + name=body.name, + description=desc, + workflow_config=workflow_config, + budget_config=body.budget_config, + user_id=current_user.id, + status="draft", + ) + db.add(agent) + db.commit() + db.refresh(agent) + logger.info( + f"用户 {current_user.username} 从模板 {body.template_id} 创建 Agent: {agent.name} ({agent.id})" + ) + return agent diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3b7906c..95488ff 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -52,6 +52,18 @@ class Settings(BaseSettings): JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production" JWT_ALGORITHM: str = "HS256" JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + # Celery 工作流任务:对**非业务节点失败**(非 WorkflowExecutionError)的退避重试次数,0 表示不重试 + WORKFLOW_TASK_MAX_RETRIES: int = 0 + + # 单执行预算:主循环每执行一个节点计 1 步,超过则熔断(防止死循环/失控) + WORKFLOW_MAX_STEPS_PER_RUN: int = 2000 + + # 单执行 LLM 节点调用上限(llm / template 节点每执行一次计 1) + WORKFLOW_MAX_LLM_INVOCATIONS_PER_RUN: int = 200 + + # 单执行工具实际执行次数上限(LLM function calling 每执行一个工具计 1) + WORKFLOW_MAX_TOOL_CALLS_PER_RUN: int = 500 class Config: env_file = str(_ENV_PATH) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 5c82c9c..d68e211 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -16,7 +16,11 @@ engine = create_engine( ) # 创建会话工厂 -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +# expire_on_commit=False:commit 后仍可读取已加载标量,避免 FastAPI 在序列化 ExecutionResponse 时 +# 因会话已关闭而再次触发懒加载,从而出现「仅 HTTP 报 DATABASE_ERROR、TestClient 正常」的现象。 +SessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=engine, expire_on_commit=False +) # 创建基础模型类 Base = declarative_base() diff --git a/backend/app/core/error_handler.py b/backend/app/core/error_handler.py index 3bbd6f4..1845e87 100644 --- a/backend/app/core/error_handler.py +++ b/backend/app/core/error_handler.py @@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import SQLAlchemyError from app.core.exceptions import BaseAPIException +from app.core.config import settings logger = logging.getLogger(__name__) @@ -50,14 +51,28 @@ async def api_exception_handler(request: Request, exc: BaseAPIException): async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): """处理数据库错误""" - logger.error(f"数据库错误: {str(exc)}", exc_info=True) - + orig = getattr(exc, "orig", None) + detail = str(orig) if orig is not None else str(exc) + logger.error(f"数据库错误: {detail}", exc_info=True) + + user_msg = "数据库操作失败,请稍后重试" + # MySQL 1054 / 常见 DDL 滞后:模型已增列但库未 alembic upgrade + if "Unknown column" in detail or "(1054," in detail or "1054" in detail: + user_msg = ( + "数据库表结构与当前代码不一致(常见为缺少 executions 新列等)。" + "请在 backend 目录执行:alembic upgrade head,然后重启 API 与 Celery。" + ) + + payload: dict = { + "error": "DATABASE_ERROR", + "message": user_msg, + } + if settings.DEBUG: + payload["detail"] = detail[:4000] + return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "error": "DATABASE_ERROR", - "message": "数据库操作失败,请稍后重试" - } + content=payload, ) @@ -65,11 +80,15 @@ async def general_exception_handler(request: Request, exc: Exception): """处理通用异常""" logger.error(f"未处理的异常: {str(exc)}", exc_info=True) logger.error(f"异常堆栈: {traceback.format_exc()}") - + + payload: dict = { + "error": "INTERNAL_ERROR", + "message": "服务器内部错误,请稍后重试", + } + if settings.DEBUG: + payload["detail"] = str(exc)[:4000] + return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "error": "INTERNAL_ERROR", - "message": "服务器内部错误,请稍后重试" - } + content=payload, ) diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py index 86c388c..d5be274 100644 --- a/backend/app/core/exceptions.py +++ b/backend/app/core/exceptions.py @@ -84,3 +84,11 @@ class WorkflowExecutionError(BaseAPIException): detail=detail, error_code="WORKFLOW_EXECUTION_ERROR" ) + + +class WorkflowPaused(Exception): + """工作流在审批节点挂起,需恢复执行(非 API 异常,由 Celery 捕获并落库)。""" + + def __init__(self, snapshot: dict): + self.snapshot = snapshot + super().__init__("workflow_awaiting_approval") diff --git a/backend/app/main.py b/backend/app/main.py index 4662652..269a74b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -201,7 +201,7 @@ async def startup_event(): # 不抛出异常,允许应用继续启动 # 注册路由 -from app.api import auth, workflows, executions, websocket, execution_logs, data_sources, agents, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools +from app.api import auth, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools app.include_router(auth.router) app.include_router(workflows.router) @@ -210,6 +210,7 @@ app.include_router(websocket.router) app.include_router(execution_logs.router) app.include_router(data_sources.router) app.include_router(agents.router) +app.include_router(platform_templates.router) app.include_router(model_configs.router) app.include_router(webhooks.router) app.include_router(template_market.router) diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index 2b275e1..0e5159e 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -16,6 +16,11 @@ class Agent(Base): name = Column(String(100), nullable=False, comment="智能体名称") description = Column(Text, comment="描述") workflow_config = Column(JSON, nullable=False, comment="工作流配置") + budget_config = Column( + JSON, + nullable=True, + comment="执行预算:max_steps/max_llm_invocations/max_tool_calls(可选,覆盖全局默认)", + ) version = Column(Integer, default=1, comment="版本号") status = Column(String(20), default="draft", comment="状态: draft/published/running/stopped") user_id = Column(CHAR(36), ForeignKey("users.id"), comment="创建者ID") diff --git a/backend/app/models/execution.py b/backend/app/models/execution.py index 3ebd309..cfe6fdb 100644 --- a/backend/app/models/execution.py +++ b/backend/app/models/execution.py @@ -17,10 +17,19 @@ class Execution(Base): workflow_id = Column(CHAR(36), ForeignKey("workflows.id"), nullable=True, comment="工作流ID") input_data = Column(JSON, comment="输入数据") output_data = Column(JSON, comment="输出数据") - status = Column(String(20), nullable=False, comment="状态: pending/running/completed/failed") + status = Column( + String(32), + nullable=False, + comment="状态: pending/running/completed/failed/awaiting_approval", + ) error_message = Column(Text, comment="错误信息") execution_time = Column(Integer, comment="执行时间(ms)") task_id = Column(String(100), comment="Celery任务ID") + parent_execution_id = Column( + CHAR(36), ForeignKey("executions.id"), nullable=True, comment="父执行ID" + ) + depth = Column(Integer, default=0, nullable=False, comment="执行深度(根为0)") + pause_state = Column(JSON, nullable=True, comment="挂起快照(审批节点 HITL,恢复时消费)") created_at = Column(DateTime, default=func.now(), comment="创建时间") # 关系 diff --git a/backend/app/services/execution_budget.py b/backend/app/services/execution_budget.py new file mode 100644 index 0000000..649cbb2 --- /dev/null +++ b/backend/app/services/execution_budget.py @@ -0,0 +1,45 @@ +""" +执行预算:全局默认 + Agent.budget_config 合并,供 WorkflowEngine 与任务入口使用。 +""" +from typing import Dict, Optional + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.agent import Agent +from app.models.execution import Execution + + +def merge_budget_for_execution(db: Session, execution: Optional[Execution]) -> Dict[str, int]: + """ + 返回合并后的整数预算(至少为 1)。 + keys: max_steps, max_llm_invocations, max_tool_calls + """ + out: Dict[str, int] = { + "max_steps": max(1, int(getattr(settings, "WORKFLOW_MAX_STEPS_PER_RUN", 2000) or 2000)), + "max_llm_invocations": max( + 1, int(getattr(settings, "WORKFLOW_MAX_LLM_INVOCATIONS_PER_RUN", 200) or 200) + ), + "max_tool_calls": max( + 1, int(getattr(settings, "WORKFLOW_MAX_TOOL_CALLS_PER_RUN", 500) or 500) + ), + } + if not execution or not execution.agent_id: + return out + ag = db.query(Agent).filter(Agent.id == execution.agent_id).first() + if not ag or not isinstance(ag.budget_config, dict): + return out + bc = ag.budget_config + mapping = { + "max_steps": "max_steps", + "max_llm_invocations": "max_llm_invocations", + "max_tool_calls": "max_tool_calls", + } + for json_key, out_key in mapping.items(): + if json_key not in bc or bc[json_key] is None: + continue + try: + out[out_key] = max(1, int(bc[json_key])) + except (TypeError, ValueError): + continue + return out diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 4e9640a..a84b46a 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -1,7 +1,7 @@ """ LLM服务 - 处理各种LLM提供商的调用 """ -from typing import Dict, Any, Optional, List +from typing import Dict, Any, Optional, List, Callable, Awaitable import json import os import re @@ -550,6 +550,7 @@ class LLMService: max_iterations: int = 5, execution_logger = None, tool_choice: Optional[str] = None, + on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None, ) -> str: """ 调用OpenAI API,支持工具调用 @@ -725,6 +726,9 @@ class LLMService: ) tool_result = json.dumps({"error": str(tool_error)}, ensure_ascii=False) + if on_tool_executed: + await on_tool_executed(tool_name) + messages.append( {"role": "tool", "tool_call_id": tool_call_id, "content": tool_result} ) @@ -749,6 +753,7 @@ class LLMService: max_iterations: int = 5, execution_logger = None, tool_choice: Optional[str] = None, + on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None, ) -> str: """ 调用DeepSeek API,支持工具调用(DeepSeek兼容OpenAI API格式) @@ -765,6 +770,7 @@ class LLMService: max_iterations=max_iterations, execution_logger=execution_logger, tool_choice=tool_choice, + on_tool_executed=on_tool_executed, ) async def call_llm_with_tools( @@ -777,6 +783,7 @@ class LLMService: max_tokens: Optional[int] = None, execution_logger = None, tool_choice: Optional[str] = None, + on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None, **kwargs ) -> str: """ @@ -805,6 +812,7 @@ class LLMService: max_tokens=max_tokens, execution_logger=execution_logger, tool_choice=tool_choice, + on_tool_executed=on_tool_executed, **kwargs ) elif provider == "deepseek": @@ -818,6 +826,7 @@ class LLMService: max_tokens=max_tokens, execution_logger=execution_logger, tool_choice=tool_choice, + on_tool_executed=on_tool_executed, **kwargs ) else: diff --git a/backend/app/services/scenario_dsl.py b/backend/app/services/scenario_dsl.py new file mode 100644 index 0000000..d707d17 --- /dev/null +++ b/backend/app/services/scenario_dsl.py @@ -0,0 +1,39 @@ +""" +统一场景 DSL(阶段 3):场景可编程输入契约,供入口 / 模板 / 外部系统对齐。 +""" +from typing import Any, Dict, List, Tuple + +from pydantic import BaseModel, Field + + +class ScenarioDSL(BaseModel): + """标准输入:目标、约束、产物、验收 + 业务载荷。""" + + version: str = "1" + scene: str = Field(default="", description="场景标识,如 customer_service / dev_codegen") + goal: str = Field(default="", description="业务目标") + constraints: List[str] = Field(default_factory=list, description="硬约束") + deliverables: List[str] = Field(default_factory=list, description="期望产出") + acceptance: List[str] = Field(default_factory=list, description="验收标准") + payload: dict[str, Any] = Field(default_factory=dict, description="扩展键值") + + +def validate_scenario_dsl(raw: Any) -> Tuple[bool, List[str]]: + """ + 校验场景 DSL。根须为 object,字段通过 Pydantic 校验。 + + Returns: + (是否通过, 错误文案列表) + """ + if not isinstance(raw, dict): + return False, ["DSL 根须为 JSON object"] + try: + ScenarioDSL.model_validate(raw) + except Exception as e: + return False, [str(e)] + return True, [] + + +def normalize_scenario_dsl(raw: dict) -> Dict[str, Any]: + """校验后得到规范 dict,便于写入节点上下文。""" + return ScenarioDSL.model_validate(raw).model_dump() diff --git a/backend/app/services/scene_templates.py b/backend/app/services/scene_templates.py new file mode 100644 index 0000000..7dbb462 --- /dev/null +++ b/backend/app/services/scene_templates.py @@ -0,0 +1,171 @@ +""" +场景模板注册:路线图「客服 / 研发 / 运维」三类最小可运行工作流,供 API 与脚本一键创建 Agent。 +""" +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +PromptBuilder = Callable[[Dict[str, Any]], str] + + +def _default_prompt_cs(params: Dict[str, Any]) -> str: + extra = (params.get("extra_instructions") or "").strip() + base = """你是企业客服场景 Agent。根据用户问题给出清晰、可执行的回答;不确定时先澄清。 +用户输入可能包含:{{input}} 或来自前序节点的合并字段。保持礼貌、简洁。""" + if extra: + return f"{base}\n\n【额外说明】\n{extra}" + return base + + +def _default_prompt_dev(params: Dict[str, Any]) -> str: + extra = (params.get("extra_instructions") or "").strip() + lang = params.get("preferred_language") or "任意合适语言" + base = f"""你是研发辅助 Agent,负责代码与设计说明。优先给出可运行示例与步骤;涉及安全/生产变更时明确风险。 +偏好语言/栈:{lang}。 +用户诉求:{{input}}""" + if extra: + return f"{base}\n\n【额外说明】\n{extra}" + return base + + +def _default_prompt_ops(params: Dict[str, Any]) -> str: + extra = (params.get("extra_instructions") or "").strip() + base = """你是运维/日志分析场景 Agent。帮助用户解读日志片段、定位可能原因与下一步排查;不要编造未提供的日志内容。 +用户输入:{{input}}""" + if extra: + return f"{base}\n\n【额外说明】\n{extra}" + return base + + +def _build_minimal_workflow( + prompt: str, + *, + temperature: float = 0.3, + enable_tools: bool = False, + tools: Optional[List[str]] = None, +) -> Dict[str, Any]: + tools = tools or [] + return { + "nodes": [ + { + "id": "start-1", + "type": "start", + "position": {"x": 80, "y": 120}, + "data": {}, + }, + { + "id": "llm-1", + "type": "llm", + "position": {"x": 320, "y": 120}, + "data": { + "prompt": prompt, + "temperature": float(temperature), + "enable_tools": enable_tools, + "tools": tools, + "selected_tools": tools, + }, + }, + { + "id": "end-1", + "type": "end", + "position": {"x": 560, "y": 120}, + "data": {}, + }, + ], + "edges": [ + { + "id": "e_start_llm", + "source": "start-1", + "target": "llm-1", + "sourceHandle": "right", + "targetHandle": "left", + }, + { + "id": "e_llm_end", + "source": "llm-1", + "target": "end-1", + "sourceHandle": "right", + "targetHandle": "left", + }, + ], + } + + +def build_workflow_for_template(template_id: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + 根据模板 ID 与参数生成 workflow_config(nodes+edges)。 + + 通用参数:temperature, enable_tools, tools(list[str]), extra_instructions, preferred_language(dev) + """ + parameters = dict(parameters or {}) + meta = SCENE_TEMPLATE_REGISTRY.get(template_id) + if not meta: + raise ValueError(f"未知模板: {template_id}") + + temperature = float(parameters.get("temperature", meta.get("default_temperature", 0.3))) + enable_tools = bool(parameters.get("enable_tools", False)) + tools = parameters.get("tools") + if tools is not None and not isinstance(tools, list): + tools = [] + elif tools is None: + tools = list(meta.get("default_tools") or []) + + prompt_fn: PromptBuilder = meta["prompt_builder"] + prompt = prompt_fn(parameters) + return _build_minimal_workflow( + prompt, + temperature=temperature, + enable_tools=enable_tools, + tools=tools if enable_tools else [], + ) + + +SCENE_TEMPLATE_REGISTRY: Dict[str, Dict[str, Any]] = { + "template_customer_service": { + "title": "客服场景", + "description": "通用客服问答与澄清(最小 LLM 链)。", + "category": "customer_service", + "default_temperature": 0.35, + "default_tools": [], + "prompt_builder": _default_prompt_cs, + }, + "template_dev_codegen": { + "title": "研发 / 代码助手", + "description": "代码与设计说明辅助(最小 LLM 链)。", + "category": "dev", + "default_temperature": 0.25, + "default_tools": [], + "prompt_builder": _default_prompt_dev, + }, + "template_ops_log_analysis": { + "title": "运维 / 日志分析", + "description": "日志解读与排查建议(最小 LLM 链)。", + "category": "ops", + "default_temperature": 0.3, + "default_tools": [], + "prompt_builder": _default_prompt_ops, + }, +} + + +def list_scene_template_meta() -> List[Dict[str, Any]]: + """供 GET 接口返回(不含 prompt_builder)。""" + out: List[Dict[str, Any]] = [] + for tid, meta in SCENE_TEMPLATE_REGISTRY.items(): + out.append( + { + "id": tid, + "title": meta["title"], + "description": meta["description"], + "category": meta.get("category"), + "default_temperature": meta.get("default_temperature"), + "parameter_hints": [ + "temperature", + "enable_tools", + "tools", + "extra_instructions", + "preferred_language(仅研发模板)", + ], + } + ) + return out diff --git a/backend/app/services/workflow_engine.py b/backend/app/services/workflow_engine.py index 0ad4a70..f941929 100644 --- a/backend/app/services/workflow_engine.py +++ b/backend/app/services/workflow_engine.py @@ -14,10 +14,14 @@ from datetime import datetime as _datetime_class from app.services.llm_service import llm_service from app.services.condition_parser import condition_parser from app.services.data_transformer import data_transformer -from app.core.exceptions import WorkflowExecutionError +from app.core.exceptions import WorkflowExecutionError, WorkflowPaused from app.core.database import SessionLocal from app.models.agent import Agent +from app.models.execution import Execution +from app.models.workflow import Workflow +from app.services.execution_logger import ExecutionLogger from app.core.config import settings +from app.services.scenario_dsl import normalize_scenario_dsl, validate_scenario_dsl logger = logging.getLogger(__name__) @@ -81,7 +85,14 @@ _CODE_NODE_SAFE_BUILTINS = { class WorkflowEngine: """工作流执行引擎""" - def __init__(self, workflow_id: str, workflow_data: Dict[str, Any], logger=None, db=None): + def __init__( + self, + workflow_id: str, + workflow_data: Dict[str, Any], + logger=None, + db=None, + budget_limits: Optional[Dict[str, Any]] = None, + ): """ 初始化工作流引擎 @@ -99,11 +110,74 @@ class WorkflowEngine: self.logger = logger self.db = db self._persist_scope_cache: Optional[Tuple[Optional[str], Optional[str]]] = None + self._initial_input_data: Optional[Dict[str, Any]] = None + self._steps_used: int = 0 + self._llm_invocations: int = 0 + self._tool_calls_used: int = 0 + self.budget_limits: Dict[str, Any] = dict(budget_limits or {}) + self._cap_steps: int = max( + 1, int(getattr(settings, "WORKFLOW_MAX_STEPS_PER_RUN", 2000) or 2000) + ) + self._cap_llm: int = max( + 1, int(getattr(settings, "WORKFLOW_MAX_LLM_INVOCATIONS_PER_RUN", 200) or 200) + ) + self._cap_tool: int = max( + 1, int(getattr(settings, "WORKFLOW_MAX_TOOL_CALLS_PER_RUN", 500) or 500) + ) + for key, attr in ( + ("max_steps", "_cap_steps"), + ("max_llm_invocations", "_cap_llm"), + ("max_tool_calls", "_cap_tool"), + ): + v = self.budget_limits.get(key) + if v is None: + continue + try: + setattr(self, attr, max(1, int(v))) + except (TypeError, ValueError): + pass # 任意入口创建引擎时确保内置工具已注册(Celery / 节点测试 / 脚本,不依赖仅 import workflow_tasks) from app.core.tools_bootstrap import ensure_builtin_tools_registered ensure_builtin_tools_registered() + def _json_safe_copy(self, obj: Any) -> Any: + """将对象转为 JSON 可序列化结构再还原,避免挂起快照中的类型问题。""" + try: + return json.loads(json.dumps(obj, default=str)) + except Exception: + return obj + + def _build_pause_snapshot( + self, + pending_node_id: str, + active_edges: List[Dict[str, Any]], + executed_nodes: set, + execution_sequence: List[str], + results: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "pending_node_id": pending_node_id, + "node_outputs": self._json_safe_copy(self.node_outputs), + "active_edges": self._json_safe_copy(active_edges), + "executed_nodes": list(executed_nodes), + "execution_sequence": list(execution_sequence), + "initial_input_data": self._json_safe_copy(self._initial_input_data or {}), + "steps_used": self._steps_used, + "llm_invocations": self._llm_invocations, + "tool_calls_used": self._tool_calls_used, + "node_results_partial": self._json_safe_copy(results), + } + + async def _on_tool_executed_budget(self, tool_name: str) -> None: + """LLM function calling 每执行一次工具时回调,计入预算。""" + _ = tool_name + self._tool_calls_used += 1 + if self._tool_calls_used > self._cap_tool: + raise WorkflowExecutionError( + detail=f"已超过工具调用预算({self._cap_tool} 次)", + ) + def _get_persist_scope(self) -> Tuple[Optional[str], Optional[str]]: """(scope_kind, scope_id) 或 (None, None),用于持久化 user_memory_*。""" if self._persist_scope_cache is None: @@ -112,6 +186,67 @@ class WorkflowEngine: self._persist_scope_cache = parse_memory_scope(self.workflow_id) return self._persist_scope_cache + def _build_subworkflow_input( + self, input_data: Dict[str, Any], input_mapping: Any + ) -> Dict[str, Any]: + """根据 mapping 组装子工作流输入。""" + if not isinstance(input_mapping, dict): + return input_data if isinstance(input_data, dict) else {"input": input_data} + + sub_input: Dict[str, Any] = {} + for k, v in input_mapping.items(): + if isinstance(v, str) and isinstance(input_data, dict): + # 支持字段名、{field}、以及嵌套路径 + vv = ( + input_data.get(v) + or input_data.get(v.strip("{}")) + or self._get_nested_value(input_data, v.strip("{}")) + ) + sub_input[k] = vv if vv is not None else v + else: + sub_input[k] = v + return sub_input + + def _resolve_subworkflow_target( + self, node_data: Dict[str, Any] + ) -> Tuple[str, str, Dict[str, Any]]: + """ + 解析子工作流目标,返回 (target_type, target_id, workflow_data)。 + target_type: workflow | agent + """ + workflow_id = str(node_data.get("workflow_id") or "").strip() + agent_id = str( + node_data.get("agent_id") + or node_data.get("target_agent_id") + or "" + ).strip() + if not workflow_id and not agent_id: + raise ValueError("subworkflow 节点缺少 workflow_id 或 agent_id") + + db = self.db or SessionLocal() + own_db = self.db is None + try: + if agent_id: + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise ValueError(f"目标 Agent 不存在: {agent_id}") + cfg = agent.workflow_config or {} + return "agent", agent_id, { + "nodes": cfg.get("nodes", []), + "edges": cfg.get("edges", []), + } + + wf = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not wf: + raise ValueError(f"目标工作流不存在: {workflow_id}") + return "workflow", workflow_id, { + "nodes": wf.nodes or [], + "edges": wf.edges or [], + } + finally: + if own_db: + db.close() + def _looks_like_vector_upsert_payload(self, d: Any) -> bool: """判断是否为向量写入/upsert 返回的元数据(非用户可见话术)。""" if not isinstance(d, dict): @@ -1090,8 +1225,64 @@ class WorkflowEngine: duration = int((time.time() - start_time) * 1000) self.logger.log_node_complete(node_id, node_type, result.get('output'), duration) return result + + elif node_type == 'approval': + # 人工审批(HITL):无 __hil_decision 时挂起,由 Celery 落库 awaiting_approval + nd = node.get('data', {}) or {} + message = nd.get('message', '需要人工审批') + approved_handle = nd.get('approved_branch', 'approved') + rejected_handle = nd.get('rejected_branch', 'rejected') + root = self._initial_input_data if isinstance(self._initial_input_data, dict) else {} + merged: Dict[str, Any] = {**root} + if isinstance(input_data, dict): + merged = {**merged, **input_data} + decision = merged.get('__hil_decision') + comment = merged.get('__hil_comment') + if decision == 'approved': + out = { + 'approved': True, + 'message': message, + 'comment': comment, + 'input': input_data, + } + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, out, duration) + return { + 'output': out, + 'status': 'success', + 'branch': approved_handle, + } + if decision == 'rejected': + out = { + 'approved': False, + 'message': message, + 'comment': comment, + 'input': input_data, + } + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, out, duration) + return { + 'output': out, + 'status': 'success', + 'branch': rejected_handle, + } + if self.logger: + self.logger.info( + f"审批节点等待人工决策: {message}", + node_id=node_id, + node_type='approval', + ) + return {'status': 'awaiting_approval'} elif node_type == 'llm' or node_type == 'template': + self._llm_invocations += 1 + if self._llm_invocations > self._cap_llm: + raise WorkflowExecutionError( + detail=f"已超过 LLM 节点调用预算({self._cap_llm} 次)", + node_id=node_id, + ) # LLM节点:调用AI模型 node_data = node.get('data', {}) logger.debug(f"[rjb] LLM节点执行: node_id={node_id}, input_data={input_data}, input_data type={type(input_data)}") @@ -1406,6 +1597,16 @@ class WorkflowEngine: _tool_choice = node_data.get("tool_choice") if not (isinstance(_tool_choice, str) and _tool_choice.strip()): _tool_choice = None + # 单次执行内工具多轮迭代(默认 5,见 llm_service);节点可配 max_tool_iterations + _tool_extra: Dict[str, Any] = {} + _mi = node_data.get("max_tool_iterations") or node_data.get( + "max_tool_call_rounds" + ) + if _mi is not None: + try: + _tool_extra["max_iterations"] = max(1, min(int(_mi), 64)) + except (TypeError, ValueError): + pass result = await llm_service.call_llm_with_tools( prompt=prompt, tools=tools, @@ -1415,6 +1616,8 @@ class WorkflowEngine: max_tokens=max_tokens, execution_logger=self.logger, tool_choice=_tool_choice, + on_tool_executed=self._on_tool_executed_budget, + **_tool_extra, ) result = self._enrich_llm_json_user_profile(result, input_data) else: @@ -4434,30 +4637,106 @@ class WorkflowEngine: 'error': f'Excel处理失败: {str(e)}。注意:需要安装openpyxl或pandas库(pip install openpyxl pandas)' } - elif node_type == 'subworkflow': - # 子工作流节点:调用其他工作流 + elif node_type == 'subworkflow' or node_type == 'invoke_agent': + # 子工作流/委派节点:调用其他工作流或 Agent node_data = node.get('data', {}) - workflow_id = node_data.get('workflow_id', '') input_mapping = node_data.get('input_mapping', {}) try: - # 将当前输入根据映射转换为子工作流输入 - sub_input = {} - if isinstance(input_mapping, dict): - for k, v in input_mapping.items(): - if isinstance(v, str) and isinstance(input_data, dict): - sub_input[k] = input_data.get(v) or input_data.get(v.strip('{}')) or v - else: - sub_input[k] = v - else: - sub_input = input_data + max_depth = int(node_data.get('max_subworkflow_depth', 2) or 2) + if max_depth < 1: + max_depth = 1 + cur_depth = 0 + if isinstance(input_data, dict): + try: + cur_depth = int(input_data.get('__subworkflow_depth', 0) or 0) + except (TypeError, ValueError): + cur_depth = 0 + if cur_depth >= max_depth: + raise ValueError( + f"子工作流调用深度超限: current={cur_depth}, max={max_depth}" + ) + + # 将当前输入根据映射转换为子工作流输入 + sub_input = self._build_subworkflow_input(input_data, input_mapping) + if isinstance(sub_input, dict): + sub_input['__subworkflow_depth'] = cur_depth + 1 + + if node_type == 'invoke_agent' and not node_data.get('agent_id'): + _aid = node_data.get('target_agent_id') + if _aid: + node_data = {**node_data, 'agent_id': _aid} + + target_type, target_id, sub_workflow_data = self._resolve_subworkflow_target(node_data) + sub_workflow_id = ( + f"agent_{target_id}" if target_type == "agent" else target_id + ) + child_execution = None + child_logger = self.logger + sub_started_at = time.time() + parent_execution_id = None + if self.logger and getattr(self.logger, "execution_id", None): + parent_execution_id = str(self.logger.execution_id) + + if self.db is not None: + child_execution = Execution( + workflow_id=target_id if target_type == "workflow" else None, + agent_id=target_id if target_type == "agent" else None, + input_data=sub_input, + status="running", + parent_execution_id=parent_execution_id, + depth=cur_depth + 1, + ) + self.db.add(child_execution) + self.db.commit() + self.db.refresh(child_execution) + child_logger = ExecutionLogger(str(child_execution.id), self.db) + child_logger.info( + f"子工作流开始执行: target_type={target_type}, target_id={target_id}", + node_id=node_id, + node_type=node_type, + data={"parent_execution_id": parent_execution_id, "depth": cur_depth + 1}, + ) + + from app.services.execution_budget import merge_budget_for_execution + + if self.db is not None and child_execution is not None: + child_budget = merge_budget_for_execution(self.db, child_execution) + else: + child_budget = self.budget_limits + child_engine = WorkflowEngine( + sub_workflow_id, + sub_workflow_data, + logger=child_logger, + db=self.db, + budget_limits=child_budget, + ) + try: + child_result = await child_engine.execute(sub_input) + if child_execution is not None: + child_execution.status = "completed" + child_execution.output_data = child_result + child_execution.execution_time = int( + (time.time() - sub_started_at) * 1000 + ) + self.db.commit() + except Exception as sub_e: + if child_execution is not None: + child_execution.status = "failed" + child_execution.error_message = str(sub_e) + child_execution.execution_time = int( + (time.time() - sub_started_at) * 1000 + ) + self.db.commit() + raise - # 实际调用子工作流的执行,这里简化为回传映射后的输入 - # TODO: 集成 WorkflowEngine 执行指定 workflow_id result = { - 'workflow_id': workflow_id, + 'target_type': target_type, + 'target_id': target_id, + 'child_execution_id': str(child_execution.id) if child_execution is not None else None, 'input': sub_input, - 'status': 'success', - 'note': '子工作流执行框架占位,需集成实际调用' + 'status': child_result.get('status', 'completed'), + 'result': child_result.get('result'), + 'node_results': child_result.get('node_results'), } exec_result = {'output': result, 'status': 'success'} if self.logger: @@ -4784,29 +5063,63 @@ class WorkflowEngine: } - async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: + async def execute( + self, + input_data: Dict[str, Any], + resume_snapshot: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: """ 执行完整工作流 Args: - input_data: 初始输入数据 + input_data: 初始输入数据(恢复执行时须包含 __hil_decision 等) + resume_snapshot: 从挂起快照恢复(与 pause_state 一致) Returns: 执行结果 """ + if not resume_snapshot and isinstance(input_data, dict): + dsl_box = input_data.get("scenario_dsl") + if dsl_box is None and isinstance(input_data.get("scenario"), dict): + dsl_box = input_data["scenario"] + if dsl_box is not None: + ok, errs = validate_scenario_dsl(dsl_box) + if not ok: + raise WorkflowExecutionError( + detail="scenario_dsl 校验失败: " + "; ".join(errs), + ) + norm = normalize_scenario_dsl(dsl_box) + input_data = {**input_data, "_scenario": norm} + + self._initial_input_data = input_data # 记录工作流开始执行 if self.logger: - self.logger.info("工作流开始执行", data={"input": input_data}) - - # 初始化节点输出 - self.node_outputs = {} - active_edges = self.edges.copy() # 活跃的边列表 - executed_nodes = set() # 已执行的节点 - execution_sequence: List[str] = [] # 实际执行顺序(用于最终输出节点选择) + self.logger.info( + "工作流开始执行", + data={"input": input_data, "resume": bool(resume_snapshot)}, + ) + + if resume_snapshot: + self.node_outputs = self._json_safe_copy(resume_snapshot.get("node_outputs", {})) + active_edges = list(resume_snapshot.get("active_edges", [])) + executed_nodes = set(resume_snapshot.get("executed_nodes", [])) + execution_sequence = list(resume_snapshot.get("execution_sequence", [])) + self._steps_used = int(resume_snapshot.get("steps_used", 0)) + self._llm_invocations = int(resume_snapshot.get("llm_invocations", 0)) + self._tool_calls_used = int(resume_snapshot.get("tool_calls_used", 0)) + results = self._json_safe_copy(resume_snapshot.get("node_results_partial", {})) + else: + # 初始化节点输出 + self.node_outputs = {} + active_edges = self.edges.copy() # 活跃的边列表 + executed_nodes = set() # 已执行的节点 + execution_sequence = [] # 实际执行顺序(用于最终输出节点选择) + self._steps_used = 0 + self._llm_invocations = 0 + self._tool_calls_used = 0 + results = {} # 按拓扑顺序执行节点(动态构建执行图) - results = {} - while True: # 构建当前活跃的执行图 execution_order = self.build_execution_graph(active_edges) @@ -4853,8 +5166,10 @@ class WorkflowEngine: break # 没有更多节点可执行 node = self.nodes[next_node_id] - executed_nodes.add(next_node_id) - execution_sequence.append(next_node_id) + is_approval = node.get("type") == "approval" + if not is_approval: + executed_nodes.add(next_node_id) + execution_sequence.append(next_node_id) # 调试:检查节点数据结构 if node.get('type') == 'llm': @@ -4873,9 +5188,29 @@ class WorkflowEngine: logger.info(f"[rjb] LLM节点输入: node_id={next_node_id}, node_input={node_input}, node_outputs keys={list(self.node_outputs.keys())}") if 'start-1' in self.node_outputs: logger.info(f"[rjb] Start节点输出内容: {self.node_outputs['start-1']}") - + + # 单执行步数预算(每执行一个节点计 1 步) + self._steps_used += 1 + if self._steps_used > self._cap_steps: + raise WorkflowExecutionError( + detail=f"已超过单执行预算上限({self._cap_steps} 步),已熔断", + node_id=next_node_id, + ) + # 执行节点 result = await self.execute_node(node, node_input) + + if result.get("status") == "awaiting_approval": + self._steps_used -= 1 + snap = self._build_pause_snapshot( + next_node_id, active_edges, executed_nodes, execution_sequence, results + ) + raise WorkflowPaused(snap) + + if is_approval: + executed_nodes.add(next_node_id) + execution_sequence.append(next_node_id) + results[next_node_id] = result # 保存节点输出 @@ -4988,6 +5323,31 @@ class WorkflowEngine: node_type='switch', data=filter_info ) + + elif node.get('type') == 'approval': + branch = result.get('branch', 'approved') + logger.info(f"[rjb] Approval节点分支过滤: node_id={next_node_id}, branch={branch}") + edges_to_keep = [] + removed_source_nodes = set() + for edge in active_edges: + if edge['source'] == next_node_id: + edge_handle = edge.get('sourceHandle') + if edge_handle == branch: + edges_to_keep.append(edge) + else: + removed_source_nodes.add(edge.get('target')) + else: + edges_to_keep.append(edge) + for edge in list(edges_to_keep): + if edge['source'] in removed_source_nodes: + edges_to_keep.remove(edge) + active_edges = edges_to_keep + if self.logger: + self.logger.info( + f"Approval节点分支过滤: branch={branch}", + node_id=next_node_id, + node_type='approval', + ) # 如果是循环节点,跳过循环体的节点(循环体已在节点内部执行) if node.get('type') in ['loop', 'foreach']: diff --git a/backend/app/services/workflow_validator.py b/backend/app/services/workflow_validator.py index 84eaaec..1f640f6 100644 --- a/backend/app/services/workflow_validator.py +++ b/backend/app/services/workflow_validator.py @@ -71,7 +71,7 @@ class WorkflowValidator: node_type = node.get('type') if not node_type: self.errors.append(f"节点 {node_id} 缺少类型") - elif node_type not in ['start', 'input', 'llm', 'condition', 'transform', 'output', 'end', 'default', 'loop', 'foreach', 'loop_end', 'agent', 'http', 'request', 'database', 'db', 'file', 'file_operation', 'schedule', 'delay', 'timer', 'webhook', 'email', 'mail', 'message_queue', 'mq', 'rabbitmq', 'kafka', 'switch', 'merge', 'wait', 'json', 'text', 'cache', 'vector_db', 'log', 'error_handler', 'csv', 'object_storage', 'slack', 'dingtalk', 'dingding', 'wechat_work', 'wecom', 'sms', 'pdf', 'image', 'excel', 'subworkflow', 'code', 'oauth', 'validator', 'batch']: + elif node_type not in ['start', 'input', 'llm', 'condition', 'transform', 'output', 'end', 'default', 'loop', 'foreach', 'loop_end', 'agent', 'http', 'request', 'database', 'db', 'file', 'file_operation', 'schedule', 'delay', 'timer', 'webhook', 'email', 'mail', 'message_queue', 'mq', 'rabbitmq', 'kafka', 'switch', 'merge', 'wait', 'json', 'text', 'cache', 'vector_db', 'log', 'error_handler', 'csv', 'object_storage', 'slack', 'dingtalk', 'dingding', 'wechat_work', 'wecom', 'sms', 'pdf', 'image', 'excel', 'subworkflow', 'invoke_agent', 'code', 'oauth', 'validator', 'batch', 'approval']: self.warnings.append(f"节点 {node_id} 使用了未知类型: {node_type}") def _validate_edges(self): diff --git a/backend/app/tasks/workflow_tasks.py b/backend/app/tasks/workflow_tasks.py index 2677a84..94c1caf 100644 --- a/backend/app/tasks/workflow_tasks.py +++ b/backend/app/tasks/workflow_tasks.py @@ -11,12 +11,16 @@ from app.services.workflow_engine import WorkflowEngine from app.services.execution_logger import ExecutionLogger from app.services.alert_service import AlertService from app.core.database import SessionLocal +from app.core.config import settings +from app.core.exceptions import WorkflowExecutionError, WorkflowPaused # 导入所有相关模型,确保关系可以正确解析 from app.models.execution import Execution from app.models.agent import Agent from app.models.workflow import Workflow +from app.services.execution_budget import merge_budget_for_execution import asyncio import time +from typing import Any, Dict, Optional def _format_task_error(e: Exception) -> str: @@ -30,22 +34,30 @@ def _format_task_error(e: Exception) -> str: return s if s else repr(e) +def _snapshot_to_jsonable(snapshot: dict) -> dict: + import json as _json + + return _json.loads(_json.dumps(snapshot, default=str)) + + @celery_app.task(bind=True) def execute_workflow_task( self, execution_id: str, workflow_id: str, workflow_data: dict, - input_data: dict + input_data: dict, + resume_snapshot: Optional[dict] = None, ): """ 执行工作流任务 - Args: + Args: execution_id: 执行记录ID workflow_id: 工作流ID workflow_data: 工作流数据(nodes和edges) input_data: 输入数据 + resume_snapshot: 从挂起恢复时的快照(与 Execution.pause_state 一致) """ db = SessionLocal() start_time = time.time() @@ -65,11 +77,59 @@ def execute_workflow_task( execution_logger = ExecutionLogger(execution_id, db) execution_logger.info("工作流任务开始执行") - # 创建工作流引擎(传入logger和db) - engine = WorkflowEngine(workflow_id, workflow_data, logger=execution_logger, db=db) - - # 执行工作流(异步) - result = asyncio.run(engine.execute(input_data)) + # 创建工作流引擎(传入logger、db、合并后的执行预算) + budget = merge_budget_for_execution(db, execution) if execution else {} + engine = WorkflowEngine( + workflow_id, + workflow_data, + logger=execution_logger, + db=db, + budget_limits=budget, + ) + + max_retries = max(0, int(getattr(settings, "WORKFLOW_TASK_MAX_RETRIES", 0) or 0)) + result: Optional[dict] = None + for attempt in range(max_retries + 1): + try: + result = asyncio.run( + engine.execute(input_data, resume_snapshot=resume_snapshot) + ) + break + except WorkflowPaused as paused: + execution = db.query(Execution).filter(Execution.id == execution_id).first() + if execution: + execution.status = "awaiting_approval" + execution.pause_state = _snapshot_to_jsonable(paused.snapshot) + execution.error_message = None + db.commit() + execution_logger.info( + "工作流在审批节点挂起,等待人工决策", + data={"pending_node_id": paused.snapshot.get("pending_node_id")}, + ) + return { + "status": "awaiting_approval", + "execution_id": execution_id, + "pending_node_id": paused.snapshot.get("pending_node_id"), + } + except WorkflowExecutionError: + raise + except Exception as run_e: + if attempt >= max_retries: + raise + delay = min(30.0, 1.5**attempt) + if execution_logger: + execution_logger.warn( + f"工作流执行异常,将退避重试 ({attempt + 1}/{max_retries}): {_format_task_error(run_e)}", + data={ + "error_code": "WORKFLOW_TASK_TRANSIENT_RETRY", + "attempt": attempt + 1, + "max_retries": max_retries, + "delay_sec": round(delay, 2), + }, + ) + time.sleep(delay) + if result is None: + raise RuntimeError("工作流执行未返回结果") # 计算执行时间 execution_time = int((time.time() - start_time) * 1000) @@ -79,6 +139,7 @@ def execute_workflow_task( execution.status = "completed" execution.output_data = result execution.execution_time = execution_time + execution.pause_state = None db.commit() # 记录执行完成日志 @@ -103,8 +164,18 @@ def execute_workflow_task( # 记录错误日志 err_text = _format_task_error(e) + err_code = getattr(e, "error_code", None) + if not err_code: + err_code = ( + "WORKFLOW_EXECUTION_ERROR" + if isinstance(e, WorkflowExecutionError) + else "WORKFLOW_TASK_ERROR" + ) if execution_logger: - execution_logger.error(f"工作流任务执行失败: {err_text}", data={"error_type": type(e).__name__}) + execution_logger.error( + f"工作流任务执行失败: {err_text}", + data={"error_type": type(e).__name__, "error_code": err_code}, + ) # 更新执行记录为失败 execution = db.query(Execution).filter(Execution.id == execution_id).first() @@ -127,3 +198,177 @@ def execute_workflow_task( finally: db.close() + + +@celery_app.task(bind=True) +def resume_workflow_task( + self, + execution_id: str, + decision: str, + comment: Optional[str] = None, +): + """在 awaiting_approval 时恢复执行(审批通过/拒绝)。""" + db = SessionLocal() + start_time = time.time() + execution_logger = None + + try: + execution = db.query(Execution).filter(Execution.id == execution_id).first() + if not execution: + return {"status": "error", "detail": "执行记录不存在"} + if execution.status != "awaiting_approval": + return { + "status": "error", + "detail": f"执行状态不是 awaiting_approval: {execution.status}", + } + if not execution.pause_state: + return {"status": "error", "detail": "缺少 pause_state"} + + if decision not in ("approved", "rejected"): + return {"status": "error", "detail": "decision 须为 approved 或 rejected"} + + snapshot = execution.pause_state + base_input: Dict[str, Any] = dict(execution.input_data or {}) + base_input["__hil_decision"] = decision + if comment: + base_input["__hil_comment"] = comment + + execution.status = "running" + execution.error_message = None + execution.input_data = base_input + db.commit() + + execution_logger = ExecutionLogger(execution_id, db) + execution_logger.info("审批恢复执行", data={"decision": decision}) + + workflow_data: dict + wf_key: str + if execution.workflow_id: + wf = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first() + if not wf: + raise RuntimeError("工作流不存在") + workflow_data = {"nodes": wf.nodes, "edges": wf.edges} + wf_key = str(execution.workflow_id) + elif execution.agent_id: + ag = db.query(Agent).filter(Agent.id == execution.agent_id).first() + if not ag or not ag.workflow_config: + raise RuntimeError("Agent 或工作流配置不存在") + workflow_data = { + "nodes": ag.workflow_config.get("nodes", []), + "edges": ag.workflow_config.get("edges", []), + } + wf_key = f"agent_{execution.agent_id}" + else: + raise RuntimeError("执行未关联工作流或 Agent") + + self.update_state(state="PROGRESS", meta={"progress": 0, "status": "running"}) + + budget = merge_budget_for_execution(db, execution) if execution else {} + engine = WorkflowEngine( + wf_key, + workflow_data, + logger=execution_logger, + db=db, + budget_limits=budget, + ) + max_retries = max(0, int(getattr(settings, "WORKFLOW_TASK_MAX_RETRIES", 0) or 0)) + result: Optional[dict] = None + for attempt in range(max_retries + 1): + try: + result = asyncio.run( + engine.execute(base_input, resume_snapshot=snapshot) + ) + break + except WorkflowPaused as paused: + ex2 = db.query(Execution).filter(Execution.id == execution_id).first() + if ex2: + ex2.status = "awaiting_approval" + ex2.pause_state = _snapshot_to_jsonable(paused.snapshot) + ex2.error_message = None + db.commit() + if execution_logger: + execution_logger.info( + "工作流再次在审批节点挂起", + data={"pending_node_id": paused.snapshot.get("pending_node_id")}, + ) + return { + "status": "awaiting_approval", + "execution_id": execution_id, + "pending_node_id": paused.snapshot.get("pending_node_id"), + } + except WorkflowExecutionError: + raise + except Exception as run_e: + if attempt >= max_retries: + raise + delay = min(30.0, 1.5**attempt) + if execution_logger: + execution_logger.warn( + f"恢复执行异常,将退避重试 ({attempt + 1}/{max_retries}): {_format_task_error(run_e)}", + data={ + "error_code": "WORKFLOW_TASK_TRANSIENT_RETRY", + "attempt": attempt + 1, + "max_retries": max_retries, + "delay_sec": round(delay, 2), + }, + ) + time.sleep(delay) + if result is None: + raise RuntimeError("工作流执行未返回结果") + + execution_time = int((time.time() - start_time) * 1000) + ex3 = db.query(Execution).filter(Execution.id == execution_id).first() + if ex3: + ex3.status = "completed" + ex3.output_data = result + ex3.execution_time = execution_time + ex3.pause_state = None + db.commit() + + if execution_logger: + execution_logger.info(f"审批后工作流执行完成,耗时: {execution_time}ms") + + if ex3: + try: + asyncio.run(AlertService.check_alerts_for_execution(db, ex3)) + except Exception as e: + if execution_logger: + execution_logger.warn(f"告警检测失败: {str(e)}") + + return { + "status": "completed", + "result": result, + "execution_time": execution_time, + } + + except Exception as e: + execution_time = int((time.time() - start_time) * 1000) + err_text = _format_task_error(e) + err_code = getattr(e, "error_code", None) + if not err_code: + err_code = ( + "WORKFLOW_EXECUTION_ERROR" + if isinstance(e, WorkflowExecutionError) + else "WORKFLOW_TASK_ERROR" + ) + if execution_logger: + execution_logger.error( + f"审批恢复执行失败: {err_text}", + data={"error_type": type(e).__name__, "error_code": err_code}, + ) + execution = db.query(Execution).filter(Execution.id == execution_id).first() + if execution: + execution.status = "failed" + execution.error_message = err_text + execution.execution_time = execution_time + db.commit() + if execution: + try: + asyncio.run(AlertService.check_alerts_for_execution(db, execution)) + except Exception as e2: + if execution_logger: + execution_logger.warn(f"告警检测失败: {str(e2)}") + raise + + finally: + db.close() diff --git a/backend/scripts/bootstrap_scene_templates.py b/backend/scripts/bootstrap_scene_templates.py new file mode 100644 index 0000000..2e9380f --- /dev/null +++ b/backend/scripts/bootstrap_scene_templates.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +场景模板库:从现有知你 Agent 复制出可复用的「模板 Agent」(草稿),便于主控台/运营选用。 + +默认创建三个模板(已存在则跳过): +- 模板-客服标准(来自 知你客服14号) +- 模板-研发多步(来自 知你客服15号) +- 模板-Loop多段(来自 知你客服16号) + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/bootstrap_scene_templates.py + +环境变量: PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD +""" +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Dict, List, Optional, Tuple + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") + +TEMPLATES: List[Tuple[str, str, str]] = [ + ("模板-客服标准", "知你客服14号", "场景模板:标准客服 + 全量工具基线"), + ("模板-研发多步", "知你客服15号", "场景模板:单节点多轮工具 / 可持续执行"), + ("模板-Loop多段", "知你客服16号", "场景模板:Loop 多段 LLM + 合并输出"), +] + + +def _login_headers() -> Dict[str, str]: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + r.raise_for_status() + token = r.json().get("access_token") + if not token: + raise RuntimeError("无 access_token") + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _find_agent_id(h: Dict[str, str], name: str) -> Optional[str]: + r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 100}, headers=h, timeout=30) + if r.status_code != 200: + return None + for a in r.json() or []: + if a.get("name") == name: + return a.get("id") + return None + + +def main() -> int: + h = _login_headers() + created: List[Dict[str, Any]] = [] + skipped: List[str] = [] + + for tpl_name, source_name, tpl_desc in TEMPLATES: + if _find_agent_id(h, tpl_name): + skipped.append(tpl_name) + continue + src_id = _find_agent_id(h, source_name) + if not src_id: + print(f"跳过 {tpl_name}:未找到源 Agent {source_name}", file=sys.stderr) + continue + dup = requests.post( + f"{BASE}/api/v1/agents/{src_id}/duplicate", + headers=h, + json={"name": tpl_name}, + timeout=120, + ) + if dup.status_code != 201: + print(f"复制失败 {tpl_name}:", dup.status_code, dup.text[:500], file=sys.stderr) + continue + new_id = dup.json()["id"] + up = requests.put( + f"{BASE}/api/v1/agents/{new_id}", + headers=h, + json={"description": tpl_desc}, + timeout=60, + ) + if up.status_code != 200: + print(f"更新描述失败 {tpl_name}:", up.text[:400], file=sys.stderr) + created.append({"id": new_id, "name": tpl_name, "source": source_name}) + + print("已跳过(已存在):", ", ".join(skipped) if skipped else "(无)") + print("新建:", json.dumps(created, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/create_complex_enterprise_agent.py b/backend/scripts/create_complex_enterprise_agent.py new file mode 100644 index 0000000..b4114b9 --- /dev/null +++ b/backend/scripts/create_complex_enterprise_agent.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +创建「企业复杂编排」示例 Agent(单 DAG、无环): + + Start → Condition(用户 query 是否含触发标记) + ├─ True: Code(预检/标注) → LLM(多内置工具) + └─ False: LLM(轻量直连) + → Merge → End + +触发深度链路:在用户问题中任意位置包含 **[[深度]]**(不含引号),例如: + 「[[深度]]请用 http_request 拉取 https://httpbin.org/get 的 JSON 并摘要」 +未包含标记则走轻量分支(仍可调 LLM,但默认关闭工具)。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/create_complex_enterprise_agent.py + USE_TESTCLIENT=1 # 不经 TCP,直接内存请求 FastAPI(推荐本机多实例抢端口时) + +环境变量: + PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD + COMPLEX_AGENT_NAME(默认 企业复杂编排_深度分流) +""" +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Dict, List + +DEEP_MARKER = "[[深度]]" +DEFAULT_NAME = os.getenv("COMPLEX_AGENT_NAME", "企业复杂编排_深度分流") + +TOOLS_DEEP = [ + "http_request", + "text_analyze", + "json_process", + "datetime", + "math_calculate", + "file_read", + "system_info", +] + +PROMPT_DEEP = f"""你是企业级「深度分析」助手。用户消息中可能含有触发标记「{DEEP_MARKER}」,回答时请忽略该标记本身,专注实质需求。 +可调用工具完成:HTTP 请求、文本分析、JSON 处理、时间、数学、读文件、系统信息。按需调用,勿编造工具结果。 +最后用简洁中文总结结论。""" + +PROMPT_FAST = """你是企业助手「快速通道」。用户未走深度编排;请直接、简洁地回答,避免冗长。若信息不足先追问一句。""" + +CODE_PREFLIGHT = """ +d = input_data if isinstance(input_data, dict) else {} +q = str(d.get("query", "") or "") +result = dict(d) +result["_deep_preflight"] = True +result["_query_chars"] = len(q) +result["_marker_detected"] = "[[深度]]" in q +""".strip() + + +def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + seen: set = set() + out: List[Dict[str, Any]] = [] + for e in edges or []: + s, t = e.get("source"), e.get("target") + if not s or not t or s == t: + continue + key = (s, t, e.get("sourceHandle") or "") + if key in seen: + continue + seen.add(key) + ne = dict(e) + if not ne.get("targetHandle"): + ne["targetHandle"] = "left" + if not ne.get("id"): + sh = ne.get("sourceHandle") or "r" + ne["id"] = f"e_{s}_{t}_{sh}" + out.append(ne) + return out + + +def build_complex_workflow() -> Dict[str, Any]: + """Condition 表达式与引擎一致:{{query}} contains \"...\" """ + cond_expr = f'{{query}} contains "{DEEP_MARKER}"' + nodes: List[Dict[str, Any]] = [ + {"id": "start-1", "type": "start", "position": {"x": 40, "y": 260}, "data": {"label": "开始"}}, + { + "id": "cond-1", + "type": "condition", + "position": {"x": 280, "y": 260}, + "data": {"label": "深度分流", "condition": cond_expr}, + }, + { + "id": "code-pf", + "type": "code", + "position": {"x": 520, "y": 120}, + "data": { + "label": "预检/标注", + "language": "python", + "code": CODE_PREFLIGHT, + "timeout": 15, + }, + }, + { + "id": "llm-deep", + "type": "llm", + "position": {"x": 760, "y": 120}, + "data": { + "label": "深度 LLM+工具", + "prompt": PROMPT_DEEP, + "temperature": 0.25, + "enable_tools": True, + "tools": TOOLS_DEEP, + "selected_tools": TOOLS_DEEP, + }, + }, + { + "id": "llm-fast", + "type": "llm", + "position": {"x": 520, "y": 400}, + "data": { + "label": "快速 LLM", + "prompt": PROMPT_FAST, + "temperature": 0.35, + "enable_tools": False, + "tools": [], + "selected_tools": [], + }, + }, + { + "id": "merge-1", + "type": "merge", + "position": {"x": 1000, "y": 260}, + "data": {"label": "合并输出", "mode": "merge_all", "strategy": "object"}, + }, + {"id": "end-1", "type": "end", "position": {"x": 1240, "y": 260}, "data": {"label": "结束"}}, + ] + edges = _sanitize_edges( + [ + {"source": "start-1", "target": "cond-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "cond-1", "target": "code-pf", "sourceHandle": "true", "targetHandle": "left"}, + {"source": "cond-1", "target": "llm-fast", "sourceHandle": "false", "targetHandle": "left"}, + {"source": "code-pf", "target": "llm-deep", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "llm-deep", "target": "merge-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "llm-fast", "target": "merge-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "merge-1", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"}, + ] + ) + return {"nodes": nodes, "edges": edges} + + +def _validate_local(wf: Dict[str, Any]) -> None: + from app.services.workflow_validator import validate_workflow + + r = validate_workflow(wf.get("nodes") or [], wf.get("edges") or []) + if not r.get("valid"): + errs = r.get("errors") or [] + raise ValueError("工作流校验失败: " + "; ".join(errs)) + + +def _via_requests() -> int: + import requests + + base = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") + user = os.getenv("PLATFORM_USERNAME", "admin") + pwd = os.getenv("PLATFORM_PASSWORD", "123456") + wf = build_complex_workflow() + _validate_local(wf) + + lr = requests.post( + f"{base}/api/v1/auth/login", + data={"username": user, "password": pwd}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=20, + ) + if lr.status_code != 200: + print("登录失败:", lr.status_code, lr.text[:500], file=sys.stderr) + return 1 + token = lr.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + def find_id() -> str | None: + rr = requests.get(f"{base}/api/v1/agents", params={"search": DEFAULT_NAME, "limit": 50}, headers=h, timeout=30) + if rr.status_code != 200: + return None + for a in rr.json() or []: + if a.get("name") == DEFAULT_NAME: + return a.get("id") + return None + + desc = ( + f"复杂编排示例:条件分流(query 含「{DEEP_MARKER}」→ 代码预检 + 多工具 LLM),否则快速 LLM;Merge 汇总后结束。" + " 适合演示画布、引擎分支与预算。" + ) + budget = {"max_steps": 120, "max_llm_invocations": 6, "max_tool_calls": 48} + existing = find_id() + if existing: + ur = requests.put( + f"{base}/api/v1/agents/{existing}", + headers=h, + json={"description": desc, "workflow_config": wf, "budget_config": budget}, + timeout=120, + ) + if ur.status_code != 200: + print("更新失败:", ur.status_code, ur.text[:800], file=sys.stderr) + return 1 + aid = existing + print("已更新:", DEFAULT_NAME, aid) + else: + cr = requests.post( + f"{base}/api/v1/agents", + headers=h, + json={ + "name": DEFAULT_NAME, + "description": desc, + "workflow_config": wf, + "budget_config": budget, + }, + timeout=120, + ) + if cr.status_code != 201: + print("创建失败:", cr.status_code, cr.text[:800], file=sys.stderr) + return 1 + aid = cr.json()["id"] + print("已创建:", DEFAULT_NAME, aid) + + print( + json.dumps( + { + "id": aid, + "name": DEFAULT_NAME, + "deep_marker": DEEP_MARKER, + "hint": f"执行时在 query 中加入 {DEEP_MARKER} 可走深度分支", + }, + ensure_ascii=False, + ) + ) + return 0 + + +def _via_testclient() -> int: + from fastapi.testclient import TestClient + + from app.main import app + + wf = build_complex_workflow() + _validate_local(wf) + + c = TestClient(app) + lr = c.post( + "/api/v1/auth/login", + data={ + "username": os.getenv("PLATFORM_USERNAME", "admin"), + "password": os.getenv("PLATFORM_PASSWORD", "123456"), + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if lr.status_code != 200: + print("login:", lr.status_code, lr.text[:400], file=sys.stderr) + return 1 + token = lr.json().get("access_token") + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + gr = c.get("/api/v1/agents", params={"search": DEFAULT_NAME, "limit": 50}, headers=h) + existing = None + if gr.status_code == 200: + for a in gr.json() or []: + if a.get("name") == DEFAULT_NAME: + existing = a.get("id") + break + + desc = ( + f"复杂编排示例:条件分流(query 含「{DEEP_MARKER}」→ 代码预检 + 多工具 LLM),否则快速 LLM;Merge 汇总后结束。" + ) + budget = {"max_steps": 120, "max_llm_invocations": 6, "max_tool_calls": 48} + if existing: + ur = c.put( + f"/api/v1/agents/{existing}", + headers=h, + json={"description": desc, "workflow_config": wf, "budget_config": budget}, + ) + if ur.status_code != 200: + print("put:", ur.status_code, ur.text[:800], file=sys.stderr) + return 1 + aid = existing + print("已更新(TestClient):", DEFAULT_NAME, aid) + else: + cr = c.post( + "/api/v1/agents", + headers=h, + json={ + "name": DEFAULT_NAME, + "description": desc, + "workflow_config": wf, + "budget_config": budget, + }, + ) + if cr.status_code != 201: + print("post:", cr.status_code, cr.text[:800], file=sys.stderr) + return 1 + aid = cr.json()["id"] + print("已创建(TestClient):", DEFAULT_NAME, aid) + + print( + json.dumps( + {"id": aid, "name": DEFAULT_NAME, "deep_marker": DEEP_MARKER}, + ensure_ascii=False, + ) + ) + return 0 + + +def main() -> int: + if os.getenv("USE_TESTCLIENT", "").strip() in ("1", "true", "yes"): + return _via_testclient() + return _via_requests() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/create_enterprise_scenario_agents.py b/backend/scripts/create_enterprise_scenario_agents.py new file mode 100644 index 0000000..e6382b9 --- /dev/null +++ b/backend/scripts/create_enterprise_scenario_agents.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +""" +批量创建多个「企业场景」示例 Agent(画布均为无环 DAG,可通过平台校验)。 + +场景一览: + 1. 企业场景_电商售后 — LLM + http / 文本分析 / 时间 + 2. 企业场景_研发联调 — LLM + http / JSON / 读文件 / 数学 + 3. 企业场景_数据分析 — LLM + 只读库查询 / JSON / 文本 / 数学(需数据源与权限) + 4. 企业场景_移动运维 — LLM + adb / 读文件 / 文本 / 系统信息 + 5. 企业场景_合规纪要 — 纯 LLM,偏合规与留痕表述 + 6. 企业场景_销售助理 — LLM + http / text_analyze / datetime + 7. 企业场景_多线路由 — Code 解析 query 中的线路标记 → Switch → 三条专精 LLM → Merge + 8. 企业场景_审批流样例 — Approval → 通过则 LLM 生成说明 / 拒绝则直接结束(首次执行会待审批) + +多线路由标记(写在用户 query 里,任选其一,无标记走默认): + 「【客服】」「【研发】」「【运维】」或同义 「[[客服]]」「[[研发]]」「[[运维]]」 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/create_enterprise_scenario_agents.py + USE_TESTCLIENT=1 # 推荐:不经 TCP + +环境变量: + PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD + ENTERPRISE_SCENARIO_NAMES 可选,逗号分隔,只创建/更新列出的 Agent 名称(完整名) +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any, Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# 图构建辅助 +# --------------------------------------------------------------------------- + + +def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + seen: set = set() + out: List[Dict[str, Any]] = [] + for e in edges or []: + s, t = e.get("source"), e.get("target") + if not s or not t or s == t: + continue + key = (s, t, e.get("sourceHandle") or "") + if key in seen: + continue + seen.add(key) + ne = dict(e) + if not ne.get("targetHandle"): + ne["targetHandle"] = "left" + if not ne.get("id"): + sh = ne.get("sourceHandle") or "r" + ne["id"] = f"e_{s}_{t}_{sh}" + out.append(ne) + return out + + +def _llm( + nid: str, + pos: Tuple[int, int], + label: str, + prompt: str, + *, + temperature: float = 0.3, + tools: Optional[List[str]] = None, + enable_tools: bool = False, +) -> Dict[str, Any]: + tlist = list(tools or []) + en = bool(enable_tools and tlist) + return { + "id": nid, + "type": "llm", + "position": {"x": pos[0], "y": pos[1]}, + "data": { + "label": label, + "prompt": prompt, + "temperature": float(temperature), + "enable_tools": en, + "tools": tlist if en else [], + "selected_tools": tlist if en else [], + }, + } + + +def _linear_start_llm_end( + llm_id: str, + llm_pos: Tuple[int, int], + llm_label: str, + prompt: str, + *, + temperature: float = 0.3, + tools: Optional[List[str]] = None, + enable_tools: bool = False, +) -> Dict[str, Any]: + nodes = [ + {"id": "start-1", "type": "start", "position": {"x": 80, "y": 200}, "data": {"label": "开始"}}, + _llm(llm_id, llm_pos, llm_label, prompt, temperature=temperature, tools=tools, enable_tools=enable_tools), + {"id": "end-1", "type": "end", "position": {"x": llm_pos[0] + 240, "y": 200}, "data": {"label": "结束"}}, + ] + edges = _sanitize_edges( + [ + {"source": "start-1", "target": llm_id, "sourceHandle": "right", "targetHandle": "left"}, + {"source": llm_id, "target": "end-1", "sourceHandle": "right", "targetHandle": "left"}, + ] + ) + return {"nodes": nodes, "edges": edges} + + +# --------------------------------------------------------------------------- +# 各场景 workflow +# --------------------------------------------------------------------------- + +WF_ECOM = _linear_start_llm_end( + "llm-1", + (360, 200), + "售后处理", + """你是电商售后场景助手。处理订单状态咨询、物流、退换货政策说明;不要编造订单号与物流单号。 +可调用工具辅助:HTTP(查公开接口时)、文本分析、时间。回答简洁、分条,必要时先澄清一项关键信息。""", + temperature=0.35, + tools=["http_request", "text_analyze", "datetime"], + enable_tools=True, +) + +WF_DEV = _linear_start_llm_end( + "llm-1", + (360, 200), + "联调助手", + """你是研发联调助手:帮助解析 API 返回、构造示例请求体、阅读本地说明文件(路径由用户提供)、简单计算。 +务必以工具返回为准,勿编造响应体。优先给出可复制的 curl 或 JSON 片段。""", + temperature=0.25, + tools=["http_request", "json_process", "file_read", "math_calculate"], + enable_tools=True, +) + +WF_DATA = _linear_start_llm_end( + "llm-1", + (360, 200), + "数据问答", + """你是企业数据问答助手:在用户提供明确需求时,可用只读 SQL(SELECT)分析业务表;可解析 JSON、做简单统计与文本摘要。 +不得执行删改;无数据源或查询失败时如实说明。回答用中文,结论在前。""", + temperature=0.28, + tools=["database_query", "json_process", "text_analyze", "math_calculate"], + enable_tools=True, +) + +WF_MOBILE = _linear_start_llm_end( + "llm-1", + (360, 200), + "端侧运维", + """你是移动端/设备侧运维助手:在用户需要时协助解读 adb 日志、读取用户指定的日志文件、结合系统信息排查。 +若环境无 adb 或权限不足,根据工具错误如实说明。不要编造日志内容。""", + temperature=0.3, + tools=["adb_log", "file_read", "text_analyze", "system_info"], + enable_tools=True, +) + +WF_COMPLIANCE = _linear_start_llm_end( + "llm-1", + (360, 200), + "合规纪要", + """你是合规与对内纪要助手。输出应:条理清晰、避免绝对化承诺、敏感处提示「需法务/合规复核」;不编造内部制度条文。 +不使用外部工具,基于用户给定材料与问题作答。""", + temperature=0.2, + tools=[], + enable_tools=False, +) + +WF_SALES = _linear_start_llm_end( + "llm-1", + (360, 200), + "销售助理", + """你是 B 端销售助理:协助整理客户痛点、撰写简短跟进话术、摘要公开产品资料(若用户提供 URL 可用 HTTP 拉取)。 +保持专业、不夸大效果;数字与条款以工具或用户提供的原文为准。""", + temperature=0.35, + tools=["http_request", "text_analyze", "datetime"], + enable_tools=True, +) + +CODE_LANE = """ +d = input_data if isinstance(input_data, dict) else {} +q = str(d.get("query", "") or "") +lane = "default" +if "【研发】" in q or "[[研发]]" in q: + lane = "dev" +elif "【运维】" in q or "[[运维]]" in q: + lane = "ops" +elif "【客服】" in q or "[[客服]]" in q: + lane = "cs" +result = dict(d) +result["lane"] = lane +""".strip() + + +def build_switch_multilane_workflow() -> Dict[str, Any]: + nodes: List[Dict[str, Any]] = [ + {"id": "start-1", "type": "start", "position": {"x": 40, "y": 280}, "data": {"label": "开始"}}, + { + "id": "code-lane", + "type": "code", + "position": {"x": 240, "y": 280}, + "data": {"label": "解析线路", "language": "python", "code": CODE_LANE, "timeout": 15}, + }, + { + "id": "sw-1", + "type": "switch", + "position": {"x": 460, "y": 280}, + "data": { + "label": "业务线路", + "field": "lane", + "cases": {"cs": "br_cs", "dev": "br_dev", "ops": "br_ops"}, + "default": "br_default", + }, + }, + _llm( + "llm-cs", + (700, 80), + "客服线", + "你是企业客服专家。用户已从多线路由进入本分支;忽略线路标记,专注解决问题。简洁、礼貌。", + temperature=0.35, + ), + _llm( + "llm-dev", + (700, 280), + "研发线", + "你是研发支持专家。用户已进入研发分支;给出可执行步骤与示例,避免空泛。", + temperature=0.28, + ), + _llm( + "llm-ops", + (700, 480), + "运维线", + "你是运维专家。用户已进入运维分支;优先给排查顺序与注意事项,不编造监控数据。", + temperature=0.3, + ), + _llm( + "llm-def", + (700, 640), + "默认线", + "你是通用企业助手。用户未指定【客服】/【研发】/【运维】线路;先简要澄清所属场景再回答。", + temperature=0.35, + ), + { + "id": "merge-1", + "type": "merge", + "position": {"x": 980, "y": 280}, + "data": {"label": "合并", "mode": "merge_all", "strategy": "object"}, + }, + {"id": "end-1", "type": "end", "position": {"x": 1220, "y": 280}, "data": {"label": "结束"}}, + ] + edges = _sanitize_edges( + [ + {"source": "start-1", "target": "code-lane", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "code-lane", "target": "sw-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "sw-1", "target": "llm-cs", "sourceHandle": "br_cs", "targetHandle": "left"}, + {"source": "sw-1", "target": "llm-dev", "sourceHandle": "br_dev", "targetHandle": "left"}, + {"source": "sw-1", "target": "llm-ops", "sourceHandle": "br_ops", "targetHandle": "left"}, + {"source": "sw-1", "target": "llm-def", "sourceHandle": "br_default", "targetHandle": "left"}, + {"source": "llm-cs", "target": "merge-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "llm-dev", "target": "merge-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "llm-ops", "target": "merge-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "llm-def", "target": "merge-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "merge-1", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"}, + ] + ) + return {"nodes": nodes, "edges": edges} + + +def build_hitl_approval_workflow() -> Dict[str, Any]: + nodes: List[Dict[str, Any]] = [ + {"id": "start-1", "type": "start", "position": {"x": 80, "y": 220}, "data": {"label": "开始"}}, + { + "id": "appr-1", + "type": "approval", + "position": {"x": 320, "y": 220}, + "data": { + "label": "人工审批", + "message": "请确认是否允许本助手根据用户 query 生成对外说明草稿(示例 HITL)。", + "approved_branch": "approved", + "rejected_branch": "rejected", + }, + }, + _llm( + "llm-ok", + (580, 120), + "通过后生成", + "审批已通过。请根据用户问题写一段简短、可发送给业务方的说明草稿(中文)。", + temperature=0.35, + ), + { + "id": "end-ok", + "type": "end", + "position": {"x": 860, "y": 120}, + "data": {"label": "结束(通过)"}, + }, + { + "id": "end-rej", + "type": "end", + "position": {"x": 580, "y": 320}, + "data": {"label": "结束(拒绝)"}, + }, + ] + edges = _sanitize_edges( + [ + {"source": "start-1", "target": "appr-1", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "appr-1", "target": "llm-ok", "sourceHandle": "approved", "targetHandle": "left"}, + {"source": "appr-1", "target": "end-rej", "sourceHandle": "rejected", "targetHandle": "left"}, + {"source": "llm-ok", "target": "end-ok", "sourceHandle": "right", "targetHandle": "left"}, + ] + ) + return {"nodes": nodes, "edges": edges} + + +ScenarioSpec = Tuple[str, str, Dict[str, Any], Dict[str, Any]] + +DEFAULT_BUDGET = {"max_steps": 100, "max_llm_invocations": 8, "max_tool_calls": 40} +SWITCH_BUDGET = {"max_steps": 160, "max_llm_invocations": 12, "max_tool_calls": 48} +HITL_BUDGET = {"max_steps": 80, "max_llm_invocations": 6, "max_tool_calls": 20} + + +def all_scenario_specs() -> List[ScenarioSpec]: + return [ + ( + "企业场景_电商售后", + "电商售后:订单/物流/退换货咨询,带 HTTP·文本·时间工具。", + WF_ECOM, + DEFAULT_BUDGET, + ), + ( + "企业场景_研发联调", + "研发联调:API/JSON/读文件/计算,适合排障与样例构造。", + WF_DEV, + DEFAULT_BUDGET, + ), + ( + "企业场景_数据分析", + "数据分析:只读 SQL + JSON/文本/数学(依赖平台数据源)。", + WF_DATA, + DEFAULT_BUDGET, + ), + ( + "企业场景_移动运维", + "移动运维:adb、读文件、文本与系统信息(依赖运行环境)。", + WF_MOBILE, + DEFAULT_BUDGET, + ), + ( + "企业场景_合规纪要", + "合规纪要:无工具,偏对内记录与风险提示。", + WF_COMPLIANCE, + HITL_BUDGET, + ), + ( + "企业场景_销售助理", + "销售助理:话术与资料摘要,带 HTTP·文本·时间。", + WF_SALES, + DEFAULT_BUDGET, + ), + ( + "企业场景_多线路由", + "多线路由:query 含【客服】/【研发】/【运维】→ Switch → 专精 LLM → Merge。", + build_switch_multilane_workflow(), + SWITCH_BUDGET, + ), + ( + "企业场景_审批流样例", + "审批样例:首跑 awaiting_approval;通过后 LLM 写说明,拒绝直接结束。", + build_hitl_approval_workflow(), + HITL_BUDGET, + ), + ] + + +def _validate_local(wf: Dict[str, Any]) -> None: + from app.services.workflow_validator import validate_workflow + + r = validate_workflow(wf.get("nodes") or [], wf.get("edges") or []) + if not r.get("valid"): + errs = r.get("errors") or [] + raise ValueError("工作流校验失败: " + "; ".join(errs)) + + +def _filter_specs( + specs: List[ScenarioSpec], only_names: Optional[List[str]] +) -> List[ScenarioSpec]: + if not only_names: + return specs + want = {n.strip() for n in only_names if n.strip()} + return [s for s in specs if s[0] in want] + + +def _upsert_agent_requests( + base: str, + headers: Dict[str, str], + name: str, + description: str, + wf: Dict[str, Any], + budget: Dict[str, Any], +) -> Tuple[str, bool]: + import requests + + def find_id() -> Optional[str]: + rr = requests.get( + f"{base}/api/v1/agents", params={"search": name, "limit": 80}, headers=headers, timeout=45 + ) + if rr.status_code != 200: + return None + for a in rr.json() or []: + if a.get("name") == name: + return a.get("id") + return None + + existing = find_id() + if existing: + ur = requests.put( + f"{base}/api/v1/agents/{existing}", + headers=headers, + json={"description": description, "workflow_config": wf, "budget_config": budget}, + timeout=120, + ) + if ur.status_code != 200: + raise RuntimeError(f"更新 {name} 失败 {ur.status_code}: {ur.text[:600]}") + return existing, False + cr = requests.post( + f"{base}/api/v1/agents", + headers=headers, + json={"name": name, "description": description, "workflow_config": wf, "budget_config": budget}, + timeout=120, + ) + if cr.status_code != 201: + raise RuntimeError(f"创建 {name} 失败 {cr.status_code}: {cr.text[:600]}") + return cr.json()["id"], True + + +def _upsert_agent_testclient( + c: Any, + headers: Dict[str, str], + name: str, + description: str, + wf: Dict[str, Any], + budget: Dict[str, Any], +) -> Tuple[str, bool]: + existing = None + gr = c.get("/api/v1/agents", params={"search": name, "limit": 80}, headers=headers) + if gr.status_code == 200: + for a in gr.json() or []: + if a.get("name") == name: + existing = a.get("id") + break + if existing: + ur = c.put( + f"/api/v1/agents/{existing}", + headers=headers, + json={"description": description, "workflow_config": wf, "budget_config": budget}, + ) + if ur.status_code != 200: + raise RuntimeError(f"更新 {name} 失败 {ur.status_code}: {ur.text[:600]}") + return existing, False + cr = c.post( + "/api/v1/agents", + headers=headers, + json={"name": name, "description": description, "workflow_config": wf, "budget_config": budget}, + ) + if cr.status_code != 201: + raise RuntimeError(f"创建 {name} 失败 {cr.status_code}: {cr.text[:600]}") + return cr.json()["id"], True + + +def main(argv: Optional[List[str]] = None) -> int: + p = argparse.ArgumentParser(description="批量创建企业场景 Agent") + p.add_argument( + "--names", + nargs="*", + help="仅处理这些完整 Agent 名称(可替代环境变量 ENTERPRISE_SCENARIO_NAMES)", + ) + args = p.parse_args(argv) + + env_filter = os.getenv("ENTERPRISE_SCENARIO_NAMES", "").strip() + only_list: Optional[List[str]] = None + if args.names: + only_list = list(args.names) + elif env_filter: + only_list = [x.strip() for x in env_filter.split(",") if x.strip()] + + specs = _filter_specs(all_scenario_specs(), only_list) + + results: List[Dict[str, Any]] = [] + errors: List[str] = [] + + use_tc = os.getenv("USE_TESTCLIENT", "").strip().lower() in ("1", "true", "yes") + + if use_tc: + from fastapi.testclient import TestClient + + from app.main import app + + c = TestClient(app) + lr = c.post( + "/api/v1/auth/login", + data={ + "username": os.getenv("PLATFORM_USERNAME", "admin"), + "password": os.getenv("PLATFORM_PASSWORD", "123456"), + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if lr.status_code != 200: + print("login:", lr.status_code, lr.text[:400], file=sys.stderr) + return 1 + token = lr.json().get("access_token") + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + for name, desc, wf, budget in specs: + try: + _validate_local(wf) + aid, created = _upsert_agent_testclient(c, headers, name, desc, wf, budget) + results.append({"name": name, "id": aid, "created": created}) + tag = "创建" if created else "更新" + print(f"[{tag}] {name} {aid}") + except Exception as e: + errors.append(f"{name}: {e}") + print(f"[失败] {name}: {e}", file=sys.stderr) + else: + import requests + + base = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") + lr = requests.post( + f"{base}/api/v1/auth/login", + data={ + "username": os.getenv("PLATFORM_USERNAME", "admin"), + "password": os.getenv("PLATFORM_PASSWORD", "123456"), + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=20, + ) + if lr.status_code != 200: + print("登录失败:", lr.status_code, lr.text[:400], file=sys.stderr) + return 1 + token = lr.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + for name, desc, wf, budget in specs: + try: + _validate_local(wf) + aid, created = _upsert_agent_requests(base, headers, name, desc, wf, budget) + results.append({"name": name, "id": aid, "created": created}) + tag = "创建" if created else "更新" + print(f"[{tag}] {name} {aid}") + except Exception as e: + errors.append(f"{name}: {e}") + print(f"[失败] {name}: {e}", file=sys.stderr) + + summary = {"ok": len(results), "fail": len(errors), "agents": results, "errors": errors} + print(json.dumps(summary, ensure_ascii=False)) + return 0 if not errors else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/create_main_agent.py b/backend/scripts/create_main_agent.py new file mode 100644 index 0000000..6c17f5e --- /dev/null +++ b/backend/scripts/create_main_agent.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +创建/更新「主控台Main Agent」:负责需求分流,不直接执行重任务。 + +输出协议(末行单行 JSON): +{ + "action": "route_agent" | "clarify" | "answer_directly", + "target_agent_name": "知你客服16号", + "target_agent_id": "", + "input": {"query": "...", "user_id": "..."}, + "reason": "路由原因", + "reply": "给用户看的话" +} +""" +from __future__ import annotations + +import copy +import json +import os +import sys +from typing import Any, Dict, Optional + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +SOURCE_NAME = os.getenv("SOURCE_AGENT_NAME", "知你客服14号") +TARGET_NAME = os.getenv("TARGET_NAME", "主控台Main Agent") + +PROMPT_MAIN = """ +你是企业 Agent 主控台入口。你负责把用户请求路由到最合适的场景 Agent。 + +路由建议: +- 需求分析/代码开发/多步执行:优先路由「知你客服16号」 +- 普通客服问答:可路由「知你客服14号」 +- 若信息不足,先澄清再路由 + +必须输出一行合法 JSON(无 markdown): +{ + "action": "route_agent" | "clarify" | "answer_directly", + "target_agent_name": "知你客服16号", + "target_agent_id": "", + "input": {"query": "{{user_input}}", "user_id": "{{user_id}}"}, + "reason": "为何这样路由", + "reply": "给用户看的一句话" +} +""".strip() + + +def _find_agent_id_by_name(h: Dict[str, str], name: str) -> Optional[str]: + r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 50}, headers=h, timeout=30) + if r.status_code != 200: + return None + for a in r.json() or []: + if a.get("name") == name: + return a.get("id") + return None + + +def _patch_main_llm(wf: Dict[str, Any]) -> None: + for n in wf.get("nodes") or []: + if n.get("id") != "llm-unified": + continue + d = n.setdefault("data", {}) + d["prompt"] = PROMPT_MAIN + d["enable_tools"] = False + d["tools"] = [] + d["selected_tools"] = [] + d["temperature"] = 0.2 + return + print("警告: 未找到节点 llm-unified", file=sys.stderr) + + +def main() -> int: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if r.status_code != 200: + print("登录失败:", r.status_code, r.text[:500], file=sys.stderr) + return 1 + token = r.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + src_id = _find_agent_id_by_name(h, SOURCE_NAME) + if not src_id: + print(f"未找到源 Agent: {SOURCE_NAME}", file=sys.stderr) + return 1 + + existing = _find_agent_id_by_name(h, TARGET_NAME) + if existing: + new_id = existing + g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30) + if g.status_code != 200: + print("读取失败:", g.text[:600], file=sys.stderr) + return 1 + agent = g.json() + print("已存在,更新:", TARGET_NAME, new_id) + else: + dup = requests.post( + f"{BASE}/api/v1/agents/{src_id}/duplicate", + headers=h, + json={"name": TARGET_NAME}, + timeout=60, + ) + if dup.status_code != 201: + print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr) + return 1 + agent = dup.json() + new_id = agent["id"] + print("已创建:", TARGET_NAME, new_id) + + wf = copy.deepcopy(agent["workflow_config"]) + _patch_main_llm(wf) + + desc = ( + "主控台路由Agent:仅做任务理解与场景路由,输出结构化 action JSON;" + "默认建议需求分析/代码开发路由到知你客服16号。" + ) + up = requests.put( + f"{BASE}/api/v1/agents/{new_id}", + headers=h, + json={"description": desc, "workflow_config": wf}, + timeout=120, + ) + if up.status_code != 200: + print("更新失败:", up.status_code, up.text[:1000], file=sys.stderr) + return 1 + + print(json.dumps({"id": new_id, "name": TARGET_NAME}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/create_router_invoke_demo_agent.py b/backend/scripts/create_router_invoke_demo_agent.py new file mode 100644 index 0000000..4a89720 --- /dev/null +++ b/backend/scripts/create_router_invoke_demo_agent.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +创建/更新「演示:主链路 invoke_agent 委派」Agent。 + +拓扑(最小可运行): + Start → invoke_agent(委派到子 Agent)→ End + +子 Agent 默认使用 **知你客服16号**(可通过环境变量 `CHILD_AGENT_NAME` 改为轻量 E2E 子 Agent 等)。 + +说明: +- `invoke_agent` 与 `subworkflow` 共用引擎实现;节点里写 `agent_id` + `input_mapping` 即可。 +- **动态按 LLM 输出切换目标 Agent** 需引擎支持从 input 解析 `agent_id`(后续迭代);本演示为 **固定委派**,用于验证链路与父子执行记录。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/create_router_invoke_demo_agent.py + +环境变量: + PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD + CHILD_AGENT_NAME(默认 知你客服16号) + DEMO_AGENT_NAME(默认 演示-主链路委派invoke) +""" +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Dict, List, Optional + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +CHILD_AGENT_NAME = os.getenv("CHILD_AGENT_NAME", "知你客服16号") +DEMO_AGENT_NAME = os.getenv("DEMO_AGENT_NAME", "演示-主链路委派invoke") + + +def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + seen: set = set() + out: List[Dict[str, Any]] = [] + for e in edges or []: + s, t = e.get("source"), e.get("target") + if not s or not t or s == t: + continue + key = (s, t) + if key in seen: + continue + seen.add(key) + ne = dict(e) + ne["sourceHandle"] = "right" + ne["targetHandle"] = "left" + if not ne.get("id"): + ne["id"] = f"edge_{s}_{t}" + out.append(ne) + return out + + +def _login_headers() -> Dict[str, str]: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + r.raise_for_status() + token = r.json().get("access_token") + if not token: + raise RuntimeError("无 access_token") + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _find_agent_id(h: Dict[str, str], name: str) -> Optional[str]: + r = requests.get( + f"{BASE}/api/v1/agents", params={"search": name, "limit": 100}, headers=h, timeout=30 + ) + if r.status_code != 200: + return None + for a in r.json() or []: + if a.get("name") == name: + return a.get("id") + return None + + +def _demo_workflow(child_agent_id: str) -> Dict[str, Any]: + return { + "nodes": [ + {"id": "start-1", "type": "start", "data": {"label": "开始"}, "position": {"x": 80, "y": 160}}, + { + "id": "invoke-1", + "type": "invoke_agent", + "data": { + "label": "委派子Agent", + "agent_id": child_agent_id, + "input_mapping": {"query": "query", "user_id": "user_id"}, + "max_subworkflow_depth": 3, + }, + "position": {"x": 380, "y": 160}, + }, + {"id": "end-1", "type": "end", "data": {"label": "结束"}, "position": {"x": 680, "y": 160}}, + ], + "edges": _sanitize_edges( + [ + { + "id": "e_start_invoke", + "source": "start-1", + "target": "invoke-1", + "sourceHandle": "right", + "targetHandle": "left", + }, + { + "id": "e_invoke_end", + "source": "invoke-1", + "target": "end-1", + "sourceHandle": "right", + "targetHandle": "left", + }, + ] + ), + } + + +def main() -> int: + h = _login_headers() + child_id = _find_agent_id(h, CHILD_AGENT_NAME) + if not child_id: + print(f"未找到子 Agent: {CHILD_AGENT_NAME}", file=sys.stderr) + return 1 + + wf = _demo_workflow(child_id) + existing = _find_agent_id(h, DEMO_AGENT_NAME) + + desc = ( + f"演示:Start→invoke_agent→End,委派到「{CHILD_AGENT_NAME}」;" + "用于验证父子执行与链路 API;非动态路由版(目标 Agent 在画布中固定)。" + ) + + if existing: + r = requests.put( + f"{BASE}/api/v1/agents/{existing}", + headers=h, + json={"description": desc, "workflow_config": wf}, + timeout=60, + ) + if r.status_code != 200: + print("更新失败:", r.status_code, r.text[:800], file=sys.stderr) + return 1 + new_id = existing + print("已更新:", DEMO_AGENT_NAME, new_id) + else: + r = requests.post( + f"{BASE}/api/v1/agents", + headers=h, + json={"name": DEMO_AGENT_NAME, "description": desc, "workflow_config": wf}, + timeout=60, + ) + if r.status_code != 201: + print("创建失败:", r.status_code, r.text[:800], file=sys.stderr) + return 1 + new_id = r.json()["id"] + print("已创建:", DEMO_AGENT_NAME, new_id) + + print(json.dumps({"id": new_id, "name": DEMO_AGENT_NAME, "child_agent_id": child_id}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/create_zhini_kefu_15.py b/backend/scripts/create_zhini_kefu_15.py new file mode 100644 index 0000000..bb04495 --- /dev/null +++ b/backend/scripts/create_zhini_kefu_15.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +从「知你客服14号」复制为「知你客服15号」: + +- **工具**:与 14 号相同(平台当前全量内置工具)。 +- **可持续执行**:在 LLM 节点写入 **max_tool_iterations**(默认 28),引擎在同一轮执行内允许多次 + 「模型 → 工具 → 模型 → …」迭代,便于长链路干活(读文件→写文件→再校验等),而非只调一次工具就结束。 +- **提示词**:强调「持续反馈、多步工具链、任务完成判定」及末行 JSON 可选字段 `task_complete` / `progress_report` 等; + 若单次无法跑完,引导用户下轮「继续」并依赖会话记忆接续。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/create_zhini_kefu_15.py + +环境变量: PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD, + SOURCE_AGENT_NAME(默认 知你客服14号), TARGET_NAME(默认 知你客服15号) +""" +from __future__ import annotations + +import copy +import json +import os +import sys +from collections import defaultdict +from typing import Any, Dict, List, Optional, Tuple + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +SOURCE_NAME = os.getenv("SOURCE_AGENT_NAME", "知你客服14号") +TARGET_NAME = os.getenv("TARGET_NAME", "知你客服15号") + +TOOLS_V15: List[str] = [ + "http_request", + "file_read", + "file_write", + "text_analyze", + "datetime", + "math_calculate", + "system_info", + "json_process", + "database_query", + "adb_log", +] + +# 与引擎 workflow_engine 中读取的字段一致(上限 64) +DEFAULT_MAX_TOOL_ITERATIONS = 28 + +PROMPT_V15_MARKER = "【知你客服 15 号 · 可持续任务执行】" + +PROMPT_V15_EXTRA = f""" + +{PROMPT_V15_MARKER} + +【角色】你是**可持续执行型**客服助手:面对需要多步工具配合的任务(如:查路径 → 读配置 → 写文件 → 再读回校验),应在**同一轮对话的一次执行**内,**连续使用工具**并根据返回结果决定下一步,直到任务完成或明确受阻;不要只做一次工具调用就结束。 + +【与 14 号的关系】继承 14 号全部内置工具与纪律;**工具列表未删减**,平台侧已为 15 号提高**单次执行内工具迭代次数**(见节点 `max_tool_iterations`)。 + +【执行策略】 +1. **多步工具链**:先 `system_info` 确认工作区再 `file_write`;需要外部信息再 `http_request`;需要数据再 `database_query`(仅 SELECT)。每一步根据上一步真实返回再决策。 +2. **持续反馈**:在最终自然语言中说明**已做步骤**与**当前结果**;勿编造工具返回。 +3. **何时停**:目标达成 → 在末行 JSON 中标明完成;缺用户输入/权限/环境 → 清楚说明缺什么。 +4. **单次装不下时**:在 `reply` 中说明进度,并建议用户**下一轮发送「继续」**;可把未完成要点写入 `user_profile` 或依赖会话记忆中的 `conversation_history` 衔接(勿用空 JSON 覆盖画像)。 + +【末行 JSON(单行)扩展字段(推荐)】 +在原有 `intent`、`reply`、`user_profile` 基础上,可增加: +- `task_complete`: boolean,本任务是否已彻底完成; +- `progress_report`: string,本轮已完成步骤的简要清单; +- `continuation_hint`: string,若 `task_complete` 为 false,提示用户下一句怎么说(如「继续」「补充 xxx」)。 + +仍须以 **一行合法 JSON** 结尾,勿用 markdown 代码围栏。 + +【纪律】继承 14 号:勿刷屏 DSML;`database_query` 仅 SELECT;`file_write` 同轮勿无故重复写入同一文件除非必要。 +""" + + +def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + seen: set = set() + out: List[Dict[str, Any]] = [] + for e in edges or []: + s, t = e.get("source"), e.get("target") + if not s or not t: + continue + if s == t: + continue + key = (s, t) + if key in seen: + continue + seen.add(key) + ne = dict(e) + ne["sourceHandle"] = "right" + ne["targetHandle"] = "left" + if not ne.get("id"): + ne["id"] = f"edge_{s}_{t}" + out.append(ne) + return out + + +def _find_start_node_ids(nodes: List[Dict[str, Any]]) -> List[str]: + ids: List[str] = [] + for n in nodes or []: + nid = n.get("id") or "" + nt = (n.get("type") or (n.get("data") or {}).get("type") or "").lower() + if nt == "start" or nid in ("start", "start-1") or str(nid).startswith("start-"): + ids.append(nid) + return ids + + +def _compute_ranks( + nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]] +) -> Dict[str, int]: + node_ids = [n["id"] for n in nodes if n.get("id")] + start_ids = _find_start_node_ids(nodes) + incoming: Dict[str, int] = {nid: 0 for nid in node_ids} + for e in edges: + s, t = e.get("source"), e.get("target") + if not s or not t or s == t: + continue + if t in incoming: + incoming[t] += 1 + if not start_ids: + start_ids = [nid for nid in node_ids if incoming.get(nid, 0) == 0] or ([node_ids[0]] if node_ids else []) + + rank: Dict[str, int] = {s: 0 for s in start_ids} + nmax = max(len(nodes), 8) + for _ in range(nmax + 5): + updated = False + for e in edges: + s, t = e.get("source"), e.get("target") + if not s or not t or s == t: + continue + if s not in rank: + continue + nv = rank[s] + 1 + if t not in rank or rank[t] < nv: + rank[t] = nv + updated = True + if not updated: + break + max_r = max(rank.values(), default=0) + for nid in node_ids: + if nid not in rank: + rank[nid] = max_r + 1 + max_r += 1 + return rank + + +def _apply_layered_positions(nodes: List[Dict[str, Any]], ranks: Dict[str, int]) -> None: + layers: Dict[int, List[str]] = defaultdict(list) + for nid, r in ranks.items(): + layers[r].append(nid) + for r in layers: + layers[r].sort() + + x0, y0 = 80.0, 140.0 + x_step = 300.0 + y_step = 110.0 + + for r in sorted(layers.keys()): + ids = layers[r] + nlen = len(ids) + y_base = y0 - (nlen - 1) * y_step / 2.0 + for j, nid in enumerate(ids): + for node in nodes: + if node.get("id") != nid: + continue + pos = node.setdefault("position", {}) + pos["x"] = x0 + r * x_step + pos["y"] = y_base + j * y_step + break + + +def improve_workflow_layout_and_edges(wf: Dict[str, Any]) -> Tuple[int, int]: + nodes = wf.get("nodes") or [] + raw_edges = wf.get("edges") or [] + loops = sum( + 1 + for e in raw_edges + if e.get("source") and e.get("target") and e.get("source") == e.get("target") + ) + clean = _sanitize_edges(raw_edges) + removed_dup = len(raw_edges) - len(clean) - loops + + wf["edges"] = clean + + ranks = _compute_ranks(nodes, clean) + _apply_layered_positions(nodes, ranks) + return loops, max(0, removed_dup) + + +def _patch_llm_unified(wf: dict, base_prompt: Optional[str] = None) -> None: + for n in wf.get("nodes") or []: + if n.get("id") != "llm-unified": + continue + d = n.setdefault("data", {}) + prompt = base_prompt if base_prompt else d.get("prompt") or "" + if PROMPT_V15_MARKER not in prompt: + prompt = (prompt.rstrip() + "\n" + PROMPT_V15_EXTRA).strip() + d["prompt"] = prompt + d["enable_tools"] = True + d["tools"] = list(TOOLS_V15) + d["selected_tools"] = list(TOOLS_V15) + d["max_tool_iterations"] = DEFAULT_MAX_TOOL_ITERATIONS + return + print("警告: 未找到节点 llm-unified", file=sys.stderr) + + +def _find_agent_id_by_name(h: Dict[str, str], name: str) -> Optional[str]: + r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 50}, headers=h, timeout=30) + if r.status_code != 200: + return None + for a in r.json() or []: + if a.get("name") == name: + return a.get("id") + return None + + +def main() -> int: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if r.status_code != 200: + print("登录失败:", r.status_code, r.text[:500], file=sys.stderr) + return 1 + token = r.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + src_id = _find_agent_id_by_name(h, SOURCE_NAME) + if not src_id: + print(f"未找到源 Agent: {SOURCE_NAME}", file=sys.stderr) + return 1 + + existing = _find_agent_id_by_name(h, TARGET_NAME) + if existing: + print("已存在", TARGET_NAME, "-> 仅更新工作流", existing) + new_id = existing + g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30) + if g.status_code != 200: + print("读取失败:", g.text, file=sys.stderr) + return 1 + agent = g.json() + else: + dup = requests.post( + f"{BASE}/api/v1/agents/{src_id}/duplicate", + headers=h, + json={"name": TARGET_NAME}, + timeout=60, + ) + if dup.status_code != 201: + print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr) + return 1 + new_id = dup.json()["id"] + agent = dup.json() + print("已创建副本:", new_id, TARGET_NAME) + + wf = copy.deepcopy(agent["workflow_config"]) + loops, dup_edges = improve_workflow_layout_and_edges(wf) + print(f"连线整理: 去掉自环 {loops} 条, 合并重复边 {dup_edges} 条") + + g2 = requests.get(f"{BASE}/api/v1/agents/{src_id}", headers=h, timeout=30) + base_prompt = None + if g2.status_code == 200: + try: + for n in g2.json().get("workflow_config", {}).get("nodes") or []: + if n.get("id") == "llm-unified": + base_prompt = (n.get("data") or {}).get("prompt") + break + except Exception: + pass + _patch_llm_unified(wf, base_prompt=base_prompt) + + desc = ( + "知你客服15号:在14号全量工具基础上,强调可持续多步执行;" + f"llm-unified 配置 max_tool_iterations={DEFAULT_MAX_TOOL_ITERATIONS}," + "单次执行内可多轮工具调用直至任务完成或明确需用户继续;输出单行 JSON,可含 task_complete/progress_report。" + ) + + up = requests.put( + f"{BASE}/api/v1/agents/{new_id}", + headers=h, + json={"description": desc, "workflow_config": wf}, + timeout=120, + ) + if up.status_code != 200: + print("更新失败:", up.status_code, up.text[:1200], file=sys.stderr) + return 1 + print("已写入工具:", ", ".join(TOOLS_V15)) + print(f"max_tool_iterations: {DEFAULT_MAX_TOOL_ITERATIONS}") + print("Agent ID:", new_id) + print(json.dumps({"id": new_id, "name": TARGET_NAME}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/create_zhini_kefu_16.py b/backend/scripts/create_zhini_kefu_16.py new file mode 100644 index 0000000..08bf35d --- /dev/null +++ b/backend/scripts/create_zhini_kefu_16.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 +""" +从「知你客服15号」复制为「知你客服16号」—— **B 能力**:画布 **Loop + 循环体内 LLM**。 + +- **主线**:Cache 等上游 → `code-build` 注入 `loop_rounds` 数组 → `loop` →(体内)`llm-subtask` → `loop_end`; + 循环结束后 → `code-merge` 将各段 LLM 输出折叠为 `right`/`reply`(并合并 `user_profile`)→ 原 `llm-unified` 之后的节点(如 Cache 写、End)。 +- **弃用节点**:原 `llm-unified` 从主链摘除并保留在画布上(无连线),避免与「多段执行」重复;真正推理在 `zhini16-llm-subtask`。 +- **工具**:与 15 相同;每段内 `max_tool_iterations` 可单独配置(默认 12)。 +- **轮数**:环境变量 `ZHINI16_LOOP_ROUNDS`(默认 3,上限 8)。 + +引擎要求:`loop` 的**第一条出边**必须指向循环体起点(脚本会对 `zhini16-loop-main` 的出边排序保证)。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/create_zhini_kefu_16.py + +环境变量: PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD, + SOURCE_AGENT_NAME(默认 知你客服15号), TARGET_NAME(默认 知你客服16号) + ZHINI16_LOOP_ROUNDS, ZHINI16_SUBTASK_MAX_TOOL_ITERATIONS +""" +from __future__ import annotations + +import copy +import json +import os +import sys +from collections import defaultdict +from typing import Any, Dict, List, Optional, Tuple + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +SOURCE_NAME = os.getenv("SOURCE_AGENT_NAME", "知你客服15号") +TARGET_NAME = os.getenv("TARGET_NAME", "知你客服16号") + +LOOP_ROUNDS = max(1, min(int(os.getenv("ZHINI16_LOOP_ROUNDS", "3")), 8)) +SUBTASK_MAX_TOOL_ITER = max(1, min(int(os.getenv("ZHINI16_SUBTASK_MAX_TOOL_ITERATIONS", "12")), 64)) + +TOOLS_V16: List[str] = [ + "http_request", + "file_read", + "file_write", + "text_analyze", + "datetime", + "math_calculate", + "system_info", + "json_process", + "database_query", + "adb_log", +] + +LLM_UNIFIED = "llm-unified" +N_CODE_BUILD = "zhini16-code-build-rounds" +N_LOOP = "zhini16-loop-main" +N_SUB_LLM = "zhini16-llm-subtask" +N_LOOP_END = "zhini16-loop-end" +N_CODE_MERGE = "zhini16-code-merge-rounds" + +PROMPT_V16_MARKER = "【知你客服 16 号 · Loop 多段执行】" + +PROMPT_V16_SUBTASK_EXTRA = f""" + +{PROMPT_V16_MARKER} + +【执行方式】当前用户请求会在**同一次 API 调用**内被拆成 **{{{{round_total}}}}** 段顺序执行;你收到的是其中**第 {{{{round_index}}}} 段**(从 0 起计),轮次标量 `round`={{{{round}}}}。每段都调用同一套工具与纪律,但请**聚焦本段可推进的子目标**,避免与前后段完全重复;若本段仅需承接上文,可简短小结并推进下一步。 + +【输出】每段末尾仍输出**一行合法 JSON**(含 `intent`、`reply`、`user_profile` 等,与 14/15 一致)。`reply` 写**本段**面向用户的可见说明;最终给用户展示时会与各段 `reply` 合并,请勿在段内假设用户已看到其他段的正文。 + +【纪律】继承 15 号:多步工具链、`database_query` 仅 SELECT、勿编造工具返回、勿刷屏 DSML。 +""" + + +CODE_MERGE_PYTHON = r""" +import json as _json + +def _parse_tail_json_obj(s): + if not isinstance(s, str): + return None + t = s.strip() + if not t: + return None + last_nl = t.rfind("\n") + last_line = t[last_nl + 1 :].strip() if last_nl >= 0 else t + if not last_line.startswith("{"): + return None + try: + o = _json.loads(last_line) + return o if isinstance(o, dict) else None + except Exception: + return None + +def _reply_from_segment(s): + if not isinstance(s, str): + return str(s) + o = _parse_tail_json_obj(s) + if o and isinstance(o.get("reply"), str) and o["reply"].strip(): + return o["reply"].strip() + return s.strip() + +parts = [] +if isinstance(input_data, dict): + if isinstance(input_data.get("input"), list): + parts = input_data.get("input") + elif isinstance(input_data.get("right"), list): + parts = input_data.get("right") +elif isinstance(input_data, list): + parts = input_data +chunks = [] +merged_profile = {} +for i, p in enumerate(parts): + if p is None: + continue + chunks.append("【第%d段】\n%s" % (i + 1, _reply_from_segment(p))) + o = _parse_tail_json_obj(p) if isinstance(p, str) else None + if o and isinstance(o.get("user_profile"), dict): + merged_profile.update(o["user_profile"]) +merged_text = "\n\n".join(chunks) if chunks else "(循环未产生有效输出)" +out = dict(input_data) if isinstance(input_data, dict) else {} +out.pop("input", None) +out["reply"] = merged_text +out["right"] = {"right": merged_text} +if merged_profile: + out["user_profile_update"] = merged_profile +result = out +""".strip() + + +def _code_build_source(rounds: List[int]) -> str: + return ( + "out = dict(input_data) if isinstance(input_data, dict) else {}\n" + f"out['loop_rounds'] = {rounds!r}\n" + "result = out\n" + ) + + +def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + seen: set = set() + out: List[Dict[str, Any]] = [] + for e in edges or []: + s, t = e.get("source"), e.get("target") + if not s or not t: + continue + if s == t: + continue + key = (s, t) + if key in seen: + continue + seen.add(key) + ne = dict(e) + ne["sourceHandle"] = "right" + ne["targetHandle"] = "left" + if not ne.get("id"): + ne["id"] = f"edge_{s}_{t}" + out.append(ne) + return out + + +def _find_start_node_ids(nodes: List[Dict[str, Any]]) -> List[str]: + ids: List[str] = [] + for n in nodes or []: + nid = n.get("id") or "" + nt = (n.get("type") or (n.get("data") or {}).get("type") or "").lower() + if nt == "start" or nid in ("start", "start-1") or str(nid).startswith("start-"): + ids.append(nid) + return ids + + +def _compute_ranks( + nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]] +) -> Dict[str, int]: + node_ids = [n["id"] for n in nodes if n.get("id")] + start_ids = _find_start_node_ids(nodes) + incoming: Dict[str, int] = {nid: 0 for nid in node_ids} + for e in edges: + s, t = e.get("source"), e.get("target") + if not s or not t or s == t: + continue + if t in incoming: + incoming[t] += 1 + if not start_ids: + start_ids = [nid for nid in node_ids if incoming.get(nid, 0) == 0] or ([node_ids[0]] if node_ids else []) + + rank: Dict[str, int] = {s: 0 for s in start_ids} + nmax = max(len(nodes), 8) + for _ in range(nmax + 5): + updated = False + for e in edges: + s, t = e.get("source"), e.get("target") + if not s or not t or s == t: + continue + if s not in rank: + continue + nv = rank[s] + 1 + if t not in rank or rank[t] < nv: + rank[t] = nv + updated = True + if not updated: + break + max_r = max(rank.values(), default=0) + for nid in node_ids: + if nid not in rank: + rank[nid] = max_r + 1 + max_r += 1 + return rank + + +def _apply_layered_positions(nodes: List[Dict[str, Any]], ranks: Dict[str, int]) -> None: + layers: Dict[int, List[str]] = defaultdict(list) + for nid, r in ranks.items(): + layers[r].append(nid) + for r in layers: + layers[r].sort() + + x0, y0 = 80.0, 140.0 + x_step = 300.0 + y_step = 110.0 + + for r in sorted(layers.keys()): + ids = layers[r] + nlen = len(ids) + y_base = y0 - (nlen - 1) * y_step / 2.0 + for j, nid in enumerate(ids): + for node in nodes: + if node.get("id") != nid: + continue + pos = node.setdefault("position", {}) + pos["x"] = x0 + r * x_step + pos["y"] = y_base + j * y_step + break + + +def improve_workflow_layout_and_edges(wf: Dict[str, Any]) -> Tuple[int, int]: + nodes = wf.get("nodes") or [] + raw_edges = wf.get("edges") or [] + loops = sum( + 1 + for e in raw_edges + if e.get("source") and e.get("target") and e.get("source") == e.get("target") + ) + clean = _sanitize_edges(raw_edges) + removed_dup = len(raw_edges) - len(clean) - loops + + wf["edges"] = clean + + ranks = _compute_ranks(nodes, clean) + _apply_layered_positions(nodes, ranks) + return loops, max(0, removed_dup) + + +def _sort_loop_out_edges(edges: List[Dict[str, Any]], loop_id: str, body_target: str) -> None: + loop_es = [e for e in edges if e.get("source") == loop_id] + non = [e for e in edges if e.get("source") != loop_id] + body = [e for e in loop_es if e.get("target") == body_target] + rest = [e for e in loop_es if e.get("target") != body_target] + edges[:] = non + body + rest + + +def _upsert_node(nodes: List[Dict[str, Any]], node: Dict[str, Any]) -> None: + nid = node.get("id") + for i, n in enumerate(nodes): + if n.get("id") == nid: + nodes[i] = node + return + nodes.append(node) + + +def _topology_already_applied(nodes: List[Dict[str, Any]]) -> bool: + return any(n.get("id") == N_LOOP for n in (nodes or [])) + + +def _apply_loop_b_topology(wf: Dict[str, Any], rounds: List[int]) -> None: + nodes = wf.setdefault("nodes", []) + edges = wf.setdefault("edges", []) + + pres: List[str] = [] + posts: List[str] = [] + seen_p: set = set() + seen_t: set = set() + for e in edges: + if e.get("target") == LLM_UNIFIED: + s = e.get("source") + if s and s not in seen_p: + seen_p.add(s) + pres.append(s) + if e.get("source") == LLM_UNIFIED: + t = e.get("target") + if t and t not in seen_t: + seen_t.add(t) + posts.append(t) + + if not pres: + print("错误: 未找到指向 llm-unified 的边,无法从 15 号主线改造。", file=sys.stderr) + raise SystemExit(2) + if not posts: + print("错误: 未找到从 llm-unified 出发的边。", file=sys.stderr) + raise SystemExit(2) + + edges[:] = [e for e in edges if e.get("target") != LLM_UNIFIED and e.get("source") != LLM_UNIFIED] + + llm_template: Optional[Dict[str, Any]] = None + for n in nodes: + if n.get("id") == LLM_UNIFIED: + llm_template = copy.deepcopy(n) + break + + sub_llm: Dict[str, Any] = { + "id": N_SUB_LLM, + "type": "llm", + "position": {"x": 0, "y": 0}, + "data": { + "label": "16号·循环体内LLM", + "prompt": "你是知你客服多段执行中的子任务模型。\n" + PROMPT_V16_SUBTASK_EXTRA, + "enable_tools": True, + "tools": list(TOOLS_V16), + "selected_tools": list(TOOLS_V16), + "max_tool_iterations": SUBTASK_MAX_TOOL_ITER, + }, + } + if llm_template: + td = llm_template.get("data") or {} + sd = sub_llm["data"] + for k in ("provider", "model", "temperature", "max_tokens"): + if k in td and td[k] is not None: + sd[k] = td[k] + base_p = (td.get("prompt") or "").strip() + if base_p and PROMPT_V16_MARKER not in base_p: + sd["prompt"] = base_p + "\n" + PROMPT_V16_SUBTASK_EXTRA.strip() + + _upsert_node( + nodes, + { + "id": N_CODE_BUILD, + "type": "code", + "position": {"x": 0, "y": 0}, + "data": { + "label": "16号·注入loop_rounds", + "language": "python", + "code": _code_build_source(rounds), + }, + }, + ) + _upsert_node( + nodes, + { + "id": N_LOOP, + "type": "loop", + "position": {"x": 0, "y": 0}, + "data": { + "label": "16号·多段循环", + "items_path": "right.loop_rounds", + "item_variable": "round", + "error_handling": "continue", + }, + }, + ) + _upsert_node(nodes, sub_llm) + _upsert_node( + nodes, + { + "id": N_LOOP_END, + "type": "loop_end", + "position": {"x": 0, "y": 0}, + "data": {"label": "16号·循环结束"}, + }, + ) + _upsert_node( + nodes, + { + "id": N_CODE_MERGE, + "type": "code", + "position": {"x": 0, "y": 0}, + "data": { + "label": "16号·合并各段输出", + "language": "python", + "code": CODE_MERGE_PYTHON, + }, + }, + ) + + new_edges: List[Dict[str, Any]] = [] + for p in pres: + new_edges.append( + { + "id": f"e_{p}_{N_CODE_BUILD}", + "source": p, + "target": N_CODE_BUILD, + "sourceHandle": "right", + "targetHandle": "left", + } + ) + new_edges.append( + { + "id": f"e_{N_CODE_BUILD}_{N_LOOP}", + "source": N_CODE_BUILD, + "target": N_LOOP, + "sourceHandle": "right", + "targetHandle": "left", + } + ) + new_edges.append( + { + "id": f"e_{N_LOOP}_{N_SUB_LLM}", + "source": N_LOOP, + "target": N_SUB_LLM, + "sourceHandle": "right", + "targetHandle": "left", + } + ) + new_edges.append( + { + "id": f"e_{N_SUB_LLM}_{N_LOOP_END}", + "source": N_SUB_LLM, + "target": N_LOOP_END, + "sourceHandle": "right", + "targetHandle": "left", + } + ) + new_edges.append( + { + "id": f"e_{N_LOOP}_{N_CODE_MERGE}", + "source": N_LOOP, + "target": N_CODE_MERGE, + "sourceHandle": "right", + "targetHandle": "left", + } + ) + for p in posts: + new_edges.append( + { + "id": f"e_{N_CODE_MERGE}_{p}", + "source": N_CODE_MERGE, + "target": p, + "sourceHandle": "right", + "targetHandle": "left", + } + ) + + edges.extend(new_edges) + _sort_loop_out_edges(edges, N_LOOP, N_SUB_LLM) + + +def _patch_zhini16_content_only(wf: Dict[str, Any], rounds: List[int]) -> None: + """已存在 16 号拓扑时,仅刷新 code 与 sub-llm 参数。""" + for n in wf.get("nodes") or []: + nid = n.get("id") + if nid == N_CODE_BUILD: + n.setdefault("data", {})["code"] = _code_build_source(rounds) + elif nid == N_SUB_LLM: + d = n.setdefault("data", {}) + d["tools"] = list(TOOLS_V16) + d["selected_tools"] = list(TOOLS_V16) + d["max_tool_iterations"] = SUBTASK_MAX_TOOL_ITER + elif nid == N_LOOP: + d = n.setdefault("data", {}) + d["items_path"] = "right.loop_rounds" + d["item_variable"] = "round" + d["error_handling"] = "continue" + elif nid == N_CODE_MERGE: + n.setdefault("data", {})["code"] = CODE_MERGE_PYTHON + + +def _find_agent_id_by_name(h: Dict[str, str], name: str) -> Optional[str]: + r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 50}, headers=h, timeout=30) + if r.status_code != 200: + return None + for a in r.json() or []: + if a.get("name") == name: + return a.get("id") + return None + + +def main() -> int: + rounds = list(range(1, LOOP_ROUNDS + 1)) + + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if r.status_code != 200: + print("登录失败:", r.status_code, r.text[:500], file=sys.stderr) + return 1 + token = r.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + src_id = _find_agent_id_by_name(h, SOURCE_NAME) + if not src_id: + print(f"未找到源 Agent: {SOURCE_NAME}", file=sys.stderr) + return 1 + + existing = _find_agent_id_by_name(h, TARGET_NAME) + if existing: + print("已存在", TARGET_NAME, "-> 仅更新工作流", existing) + new_id = existing + g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30) + if g.status_code != 200: + print("读取失败:", g.text, file=sys.stderr) + return 1 + agent = g.json() + else: + dup = requests.post( + f"{BASE}/api/v1/agents/{src_id}/duplicate", + headers=h, + json={"name": TARGET_NAME}, + timeout=60, + ) + if dup.status_code != 201: + print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr) + return 1 + new_id = dup.json()["id"] + agent = dup.json() + print("已创建副本:", new_id, TARGET_NAME) + + wf = copy.deepcopy(agent["workflow_config"]) + + if _topology_already_applied(wf.get("nodes") or []): + _patch_zhini16_content_only(wf, rounds) + else: + _apply_loop_b_topology(wf, rounds) + + loops, dup_edges = improve_workflow_layout_and_edges(wf) + print(f"连线整理: 去掉自环 {loops} 条, 合并重复边 {dup_edges} 条") + + desc = ( + "知你客服16号:B能力——Loop+循环体内LLM;" + f"默认 {LOOP_ROUNDS} 段顺序执行,每段节点 zhini16-llm-subtask 工具迭代上限 {SUBTASK_MAX_TOOL_ITER};" + "原 llm-unified 已从主链摘除;各段 reply 经 zhini16-code-merge-rounds 合并后写入下游 Cache/End。" + ) + + up = requests.put( + f"{BASE}/api/v1/agents/{new_id}", + headers=h, + json={"description": desc, "workflow_config": wf}, + timeout=120, + ) + if up.status_code != 200: + print("更新失败:", up.status_code, up.text[:1200], file=sys.stderr) + return 1 + print("loop_rounds:", rounds) + print("工具:", ", ".join(TOOLS_V16)) + print("Agent ID:", new_id) + print(json.dumps({"id": new_id, "name": TARGET_NAME}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/e2e_enterprise_multilane_agent.py b/backend/scripts/e2e_enterprise_multilane_agent.py new file mode 100644 index 0000000..b1d1380 --- /dev/null +++ b/backend/scripts/e2e_enterprise_multilane_agent.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +测试「企业场景_多线路由」Agent: + + - 查找同名 Agent → POST /api/v1/executions(多条 query:默认线 / 【客服】/【研发】) + - 轮询 GET /api/v1/executions/{id} 直到终态(需 Celery Worker + LLM,否则会 pending/失败) + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/e2e_enterprise_multilane_agent.py + +环境变量: + PLATFORM_BASE_URL 默认 http://127.0.0.1:8037 + PLATFORM_USERNAME / PLATFORM_PASSWORD + AGENT_NAME 默认 企业场景_多线路由 + USE_TESTCLIENT=1 不经 TCP(仅验证创建执行入队;轮询仍走 TestClient) + POLL_TIMEOUT_S 默认 180 + SKIP_POLL=1 只创建执行、不等待终态 +""" +from __future__ import annotations + +import json +import os +import sys +import time +from typing import Any, Dict, List, Optional, Tuple + +AGENT_NAME = os.getenv("AGENT_NAME", "企业场景_多线路由").strip() or "企业场景_多线路由" +POLL_TIMEOUT_S = float(os.getenv("POLL_TIMEOUT_S", "180")) +SKIP_POLL = os.getenv("SKIP_POLL", "").strip().lower() in ("1", "true", "yes") + +# 与 create_enterprise_scenario_agents.py 中 Code 节点约定一致 +CASES: List[Tuple[str, str]] = [ + ("default", "你好,请用一句话说明你能做什么。"), + ("cs", "【客服】订单物流一般几天能到?"), + ("dev", "[[研发]]写一个 Python 读取 JSON 文件的最小示例思路。"), +] + + +def _login_requests(base: str) -> Dict[str, str]: + import requests + + r = requests.post( + f"{base}/api/v1/auth/login", + data={ + "username": os.getenv("PLATFORM_USERNAME", "admin"), + "password": os.getenv("PLATFORM_PASSWORD", "123456"), + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=20, + ) + r.raise_for_status() + token = r.json().get("access_token") + if not token: + raise RuntimeError("无 access_token") + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _find_agent_id_requests(base: str, h: Dict[str, str]) -> Optional[str]: + import requests + + r = requests.get( + f"{base}/api/v1/agents", + params={"search": AGENT_NAME, "limit": 50}, + headers=h, + timeout=45, + ) + r.raise_for_status() + for a in r.json() or []: + if isinstance(a, dict) and a.get("name") == AGENT_NAME: + return a.get("id") + return None + + +def _post_execution_requests(base: str, h: Dict[str, str], agent_id: str, query: str) -> Dict[str, Any]: + import requests + + r = requests.post( + f"{base}/api/v1/executions", + headers=h, + json={ + "agent_id": agent_id, + "input_data": {"query": query, "USER_INPUT": query}, + }, + timeout=60, + ) + if r.status_code not in (200, 201): + raise RuntimeError(f"创建执行失败 {r.status_code}: {r.text[:1200]}") + return r.json() + + +def _get_execution_requests(base: str, h: Dict[str, str], eid: str) -> Dict[str, Any]: + import requests + + r = requests.get(f"{base}/api/v1/executions/{eid}", headers=h, timeout=45) + r.raise_for_status() + return r.json() + + +def _poll_requests(base: str, h: Dict[str, str], eid: str) -> Dict[str, Any]: + t0 = time.time() + while time.time() - t0 < POLL_TIMEOUT_S: + d = _get_execution_requests(base, h, eid) + st = d.get("status") + if st in ("completed", "failed", "awaiting_approval"): + return d + time.sleep(1.2) + raise TimeoutError(f"执行 {eid} 在 {POLL_TIMEOUT_S}s 内未结束") + + +def _run_http() -> int: + import requests + + base = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") + + try: + hr = requests.get(f"{base}/health", timeout=8) + print("GET /health", hr.status_code, hr.text[:300]) + except Exception as e: + print("GET /health 失败:", e, file=sys.stderr) + + try: + h = _login_requests(base) + except Exception as e: + print("登录失败:", e, file=sys.stderr) + return 1 + + aid = _find_agent_id_requests(base, h) + if not aid: + print(f"未找到 Agent: {AGENT_NAME}(请先运行 create_enterprise_scenario_agents.py)", file=sys.stderr) + return 2 + + print(f"Agent: {AGENT_NAME} id={aid}") + + results: List[Dict[str, Any]] = [] + for tag, query in CASES: + print(f"\n--- 用例 [{tag}] query 前缀: {query[:40]!r} ...") + try: + ex = _post_execution_requests(base, h, aid, query) + except Exception as e: + print("创建执行失败:", e, file=sys.stderr) + return 3 + eid = ex.get("id") + print(f" execution_id={eid} status={ex.get('status')} task_id={ex.get('task_id')}") + if SKIP_POLL: + results.append({"tag": tag, "execution_id": eid, "skipped_poll": True}) + continue + try: + final = _poll_requests(base, h, str(eid)) + except TimeoutError as te: + print(" ", te, file=sys.stderr) + results.append( + { + "tag": tag, + "execution_id": eid, + "error": str(te), + "last_status": ex.get("status"), + } + ) + continue + err = final.get("error_message") + print(f" 终态: {final.get('status')} error_message={err!r}") + out = final.get("output_data") + if isinstance(out, dict): + preview = json.dumps(out, ensure_ascii=False)[:600] + else: + preview = str(out)[:600] if out else "" + print(f" output_data 预览: {preview}") + results.append( + { + "tag": tag, + "execution_id": eid, + "status": final.get("status"), + "error_message": err, + "has_output": bool(out), + } + ) + + print("\n汇总:", json.dumps(results, ensure_ascii=False, indent=2)) + failed = [x for x in results if x.get("status") == "failed" or x.get("error")] + return 0 if not failed else 4 + + +def _run_testclient() -> int: + from fastapi.testclient import TestClient + + from app.main import app + + c = TestClient(app) + r = c.post( + "/api/v1/auth/login", + data={ + "username": os.getenv("PLATFORM_USERNAME", "admin"), + "password": os.getenv("PLATFORM_PASSWORD", "123456"), + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if r.status_code != 200: + print("login:", r.status_code, r.text[:400], file=sys.stderr) + return 1 + token = r.json().get("access_token") + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + gr = c.get("/api/v1/agents", params={"search": AGENT_NAME, "limit": 50}, headers=h) + aid = None + if gr.status_code == 200: + for a in gr.json() or []: + if isinstance(a, dict) and a.get("name") == AGENT_NAME: + aid = a.get("id") + break + if not aid: + print(f"未找到 Agent: {AGENT_NAME}", file=sys.stderr) + return 2 + + print(f"[TestClient] Agent: {AGENT_NAME} id={aid}") + + for tag, query in CASES: + pr = c.post( + "/api/v1/executions", + headers=h, + json={"agent_id": aid, "input_data": {"query": query, "USER_INPUT": query}}, + ) + if pr.status_code not in (200, 201): + print(f"[{tag}] POST executions {pr.status_code}", pr.text[:800], file=sys.stderr) + return 3 + body = pr.json() + print(f"[{tag}] created execution_id={body.get('id')} status={body.get('status')}") + + print("TestClient 模式仅验证「创建执行」成功;完整跑图请在 USE_TESTCLIENT=0 且 Celery+LLM 可用时测试。") + return 0 + + +def main() -> int: + if os.getenv("USE_TESTCLIENT", "").strip().lower() in ("1", "true", "yes"): + return _run_testclient() + return _run_http() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/e2e_platform_capability_smoke.py b/backend/scripts/e2e_platform_capability_smoke.py new file mode 100644 index 0000000..003be70 --- /dev/null +++ b/backend/scripts/e2e_platform_capability_smoke.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +常用场景验证:模板化客服 Agent + 统一 DSL 执行 + 执行链可读 + +验证点(与《90 天路线图》对齐): + - GET /api/v1/agents/scene-templates 场景模板可列举 + - POST /api/v1/agents/from-scene-template 一键生成可执行 Agent + - POST /api/v1/executions 携带 scenario_dsl 的执行(引擎入口校验 + _scenario) + - GET /api/v1/executions/{id} 终态 + - GET /api/v1/execution-logs/executions/{id}/chain/summary 链路汇总(根执行至少 1 条) + +前置: API 已启动;已执行 alembic;Celery Worker 已启动(否则执行会长期 pending)。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/e2e_platform_capability_smoke.py + +环境变量: PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD +""" +from __future__ import annotations + +import os +import sys +import time +from typing import Any, Dict + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") + + +def _h() -> Dict[str, str]: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + r.raise_for_status() + token = r.json().get("access_token") + if not token: + raise RuntimeError("无 access_token") + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _wait_terminal(h: Dict[str, str], eid: str, timeout_s: float = 120.0) -> Dict[str, Any]: + t0 = time.time() + while time.time() - t0 < timeout_s: + r = requests.get(f"{BASE}/api/v1/executions/{eid}", headers=h, timeout=30) + r.raise_for_status() + d = r.json() + st = d.get("status") + if st in ("completed", "failed", "awaiting_approval"): + return d + time.sleep(1.0) + raise TimeoutError(f"执行未在 {timeout_s}s 内结束: {eid}") + + +def main() -> int: + # 0) 健康检查 + try: + hr = requests.get(f"{BASE}/health", timeout=5) + print("health:", hr.status_code, hr.text[:200]) + except Exception as e: + print("health 请求失败(可忽略):", e, file=sys.stderr) + + try: + h = _h() + except Exception as e: + print("登录失败:", e, file=sys.stderr) + return 1 + + # 1) 场景模板列表 + tr = requests.get(f"{BASE}/api/v1/platform/scene-templates", headers=h, timeout=30) + if tr.status_code != 200: + print("scene-templates:", tr.status_code, tr.text[:800], file=sys.stderr) + return 2 + templates = tr.json() or [] + print(f"scene-templates: {len(templates)} 个") + tid = "template_customer_service" + ids = [x.get("id") for x in templates if isinstance(x, dict)] + if tid not in ids: + print(f"警告: 未找到 {tid},使用列表第一项", file=sys.stderr) + tid = ids[0] if ids else None + if not tid: + print("无可用模板", file=sys.stderr) + return 3 + + # 2) 从模板创建 Agent(带轻量预算,验证 DB + 合并逻辑;可按需改大) + name = os.getenv("SMOKE_AGENT_NAME") or f"冒烟_客服DSL_{int(time.time())}" + cr = requests.post( + f"{BASE}/api/v1/platform/agents/from-template", + headers=h, + json={ + "template_id": tid, + "name": name, + "description": "e2e_platform_capability_smoke 自动创建", + "parameters": {"temperature": 0.35}, + "budget_config": { + "max_steps": 50, + "max_llm_invocations": 5, + "max_tool_calls": 20, + }, + }, + timeout=60, + ) + if cr.status_code not in (200, 201): + print("from-scene-template:", cr.status_code, cr.text[:1200], file=sys.stderr) + return 4 + agent = cr.json() + aid = agent.get("id") + print("created agent:", aid, agent.get("name")) + + # 3) 携带 scenario_dsl 的执行(统一输入契约) + user_msg = os.getenv("SMOKE_USER_QUERY") or "你好,请用一句话说明你能帮我做什么。" + body = { + "agent_id": aid, + "input_data": { + "query": user_msg, + "scenario_dsl": { + "version": "1", + "scene": "customer_service_smoke", + "goal": "验证平台 DSL + 模板 Agent 执行", + "constraints": ["回答简短", "不要编造联系方式"], + "deliverables": ["一句中文回复"], + "acceptance": ["有明确语义"], + "payload": {"channel": "smoke", "user_id": "e2e_smoke_user"}, + }, + }, + } + er = requests.post(f"{BASE}/api/v1/executions", headers=h, json=body, timeout=30) + if er.status_code not in (200, 201): + print("executions:", er.status_code, er.text[:1200], file=sys.stderr) + return 5 + eid = er.json()["id"] + print("execution:", eid, "status=pending/running,等待 Celery …") + + try: + fin = _wait_terminal(h, eid, timeout_s=float(os.getenv("SMOKE_WAIT_SEC", "120"))) + except TimeoutError as te: + print(str(te), file=sys.stderr) + print("若长期 running:请确认 Celery Worker 已启动且 REDIS 可用。", file=sys.stderr) + return 6 + + st = fin.get("status") + print("final status:", st) + if st == "failed": + print("error_message:", fin.get("error_message"), file=sys.stderr) + return 7 + if st == "awaiting_approval": + print("停在审批节点(本冒烟未配置 approval),请检查工作流。", file=sys.stderr) + return 8 + + out = fin.get("output_data") + preview = str(out)[:500] if out is not None else "" + print("output_data preview:", preview) + + # 4) 执行链汇总(单节点链也应返回 total_executions>=1) + sr = requests.get( + f"{BASE}/api/v1/execution-logs/executions/{eid}/chain/summary", + headers=h, + timeout=30, + ) + if sr.status_code == 200: + s = sr.json() + print( + "chain/summary:", + "total_executions=", + s.get("total_executions"), + "status_count=", + s.get("status_count"), + "max_depth=", + s.get("max_depth"), + ) + else: + print("chain/summary:", sr.status_code, sr.text[:400], file=sys.stderr) + + print("OK — 常用场景(模板 + DSL + 执行 + 链)验证完成。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/e2e_platform_capability_smoke_testclient.py b/backend/scripts/e2e_platform_capability_smoke_testclient.py new file mode 100644 index 0000000..7ac4bb0 --- /dev/null +++ b/backend/scripts/e2e_platform_capability_smoke_testclient.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +与 e2e_platform_capability_smoke.py 相同业务验证,但走 FastAPI TestClient(不经过 TCP 8037)。 + +适用:本机存在多个进程占用 8037、HTTP 命中旧实例导致 /platform 路由 404 时。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/e2e_platform_capability_smoke_testclient.py +""" +from __future__ import annotations + +import os +import sys +import time + +from fastapi.testclient import TestClient + +# 确保加载 backend 目录 +def main() -> int: + os.environ.setdefault("PLATFORM_BASE_URL", "http://testserver") + from app.main import app + + c = TestClient(app) + r = c.post( + "/api/v1/auth/login", + data={"username": os.getenv("PLATFORM_USERNAME", "admin"), "password": os.getenv("PLATFORM_PASSWORD", "123456")}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if r.status_code != 200: + print("login:", r.status_code, r.text[:500], file=sys.stderr) + return 1 + token = r.json().get("access_token") + h = {"Authorization": f"Bearer {token}"} + + g = c.get("/api/v1/platform/scene-templates", headers=h) + if g.status_code != 200: + print("scene-templates:", g.status_code, g.text[:500], file=sys.stderr) + return 2 + templates = g.json() or [] + tid = "template_customer_service" + ids = [x.get("id") for x in templates if isinstance(x, dict)] + if tid not in ids: + tid = ids[0] if ids else None + if not tid: + print("无模板", file=sys.stderr) + return 3 + + name = os.getenv("SMOKE_AGENT_NAME") or f"冒烟_客服DSL_TC_{int(time.time())}" + cr = c.post( + "/api/v1/platform/agents/from-template", + headers=h, + json={ + "template_id": tid, + "name": name, + "description": "e2e testclient", + "parameters": {"temperature": 0.35}, + "budget_config": {"max_steps": 50, "max_llm_invocations": 5, "max_tool_calls": 20}, + }, + ) + if cr.status_code not in (200, 201): + print("from-template:", cr.status_code, cr.text[:800], file=sys.stderr) + return 4 + aid = cr.json().get("id") + print("agent:", aid, cr.json().get("name")) + + body = { + "agent_id": aid, + "input_data": { + "query": os.getenv("SMOKE_USER_QUERY") or "你好,请用一句话说明你能帮我做什么。", + "scenario_dsl": { + "version": "1", + "scene": "customer_service_smoke_tc", + "goal": "验证 TestClient 执行", + "constraints": ["简短"], + "deliverables": ["一句回复"], + "acceptance": ["有内容"], + "payload": {"channel": "smoke_tc"}, + }, + }, + } + er = c.post("/api/v1/executions", headers=h, json=body) + if er.status_code not in (200, 201): + print("executions:", er.status_code, er.text[:800], file=sys.stderr) + return 5 + eid = er.json()["id"] + print("execution:", eid, "(异步任务需 Celery;此处仅校验创建成功)") + print("OK — TestClient 路径:模板 + DSL 创建执行记录成功。请用 Celery 跑完后查 GET /api/v1/executions/{id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/e2e_router_invoke_demo.py b/backend/scripts/e2e_router_invoke_demo.py new file mode 100644 index 0000000..c97279b --- /dev/null +++ b/backend/scripts/e2e_router_invoke_demo.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +E2E: 演示 Agent「演示-主链路委派invoke」执行成功,并校验父子执行链。 + +前置: + - 已运行 create_router_invoke_demo_agent.py + - 平台 API 可访问 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/e2e_router_invoke_demo.py +""" +from __future__ import annotations + +import os +import sys +import time +from typing import Any, Dict, Optional + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +DEMO_AGENT_NAME = os.getenv("DEMO_AGENT_NAME", "演示-主链路委派invoke") + + +def _login_headers() -> Dict[str, str]: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + r.raise_for_status() + token = r.json().get("access_token") + if not token: + raise RuntimeError("无 access_token") + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _find_demo_id(h: Dict[str, str]) -> Optional[str]: + r = requests.get( + f"{BASE}/api/v1/agents", params={"search": DEMO_AGENT_NAME, "limit": 50}, headers=h, timeout=30 + ) + r.raise_for_status() + for a in r.json() or []: + if a.get("name") == DEMO_AGENT_NAME: + return a.get("id") + return None + + +def _wait_done(h: Dict[str, str], eid: str, timeout_s: float = 180.0) -> Dict[str, Any]: + t0 = time.time() + while time.time() - t0 < timeout_s: + r = requests.get(f"{BASE}/api/v1/executions/{eid}", headers=h, timeout=30) + r.raise_for_status() + d = r.json() + st = d.get("status") + if st in ("completed", "failed"): + return d + time.sleep(1.2) + raise TimeoutError(eid) + + +def main() -> int: + h = _login_headers() + aid = _find_demo_id(h) + if not aid: + print(f"未找到 Agent: {DEMO_AGENT_NAME},请先运行 create_router_invoke_demo_agent.py", file=sys.stderr) + return 1 + + body = { + "agent_id": aid, + "input_data": {"query": "E2E 委派演示:请简短回复收到。", "user_id": "e2e_router_user"}, + } + cr = requests.post(f"{BASE}/api/v1/executions", headers=h, json=body, timeout=30) + if cr.status_code >= 400: + print(cr.status_code, cr.text[:1200], file=sys.stderr) + return 2 + eid = cr.json()["id"] + print(f"execution={eid}") + + done = _wait_done(h, eid) + if done.get("status") != "completed": + print("failed:", done.get("error_message"), file=sys.stderr) + return 3 + + sm = requests.get( + f"{BASE}/api/v1/execution-logs/executions/{eid}/chain/summary", headers=h, timeout=30 + ) + if sm.status_code == 200: + s = sm.json() + print( + "chain_summary:", + s.get("total_executions"), + s.get("status_count"), + s.get("total_execution_time_ms"), + ) + if (s.get("total_executions") or 0) < 2: + print("警告: 期望至少 2 条执行记录(父+子),请确认 API 已重启到最新代码", file=sys.stderr) + elif sm.status_code == 404: + print("chain/summary 404:当前 API 进程可能未加载最新路由,跳过汇总校验") + else: + print("chain/summary", sm.status_code, sm.text[:300]) + + print("E2E 通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/e2e_subworkflow_chain.py b/backend/scripts/e2e_subworkflow_chain.py new file mode 100644 index 0000000..9afb7d0 --- /dev/null +++ b/backend/scripts/e2e_subworkflow_chain.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +E2E: 验证 subworkflow 真执行 + 父子 execution 关联链路。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/e2e_subworkflow_chain.py +""" +from __future__ import annotations + +import os +import time +from typing import Any, Dict, Optional + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") + +CHILD_NAME = "E2E-Subworkflow-Child" +PARENT_NAME = "E2E-Subworkflow-Parent" + + +def _login_headers() -> Dict[str, str]: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + r.raise_for_status() + token = r.json().get("access_token") + if not token: + raise RuntimeError("登录成功但缺少 access_token") + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _find_agent_id(h: Dict[str, str], name: str) -> Optional[str]: + r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 100}, headers=h, timeout=30) + r.raise_for_status() + for a in r.json() or []: + if a.get("name") == name: + return a.get("id") + return None + + +def _create_or_update_agent( + h: Dict[str, str], name: str, desc: str, wf: Dict[str, Any] +) -> str: + aid = _find_agent_id(h, name) + if aid: + r = requests.put( + f"{BASE}/api/v1/agents/{aid}", + headers=h, + json={"description": desc, "workflow_config": wf}, + timeout=30, + ) + r.raise_for_status() + return aid + + r = requests.post( + f"{BASE}/api/v1/agents", + headers=h, + json={"name": name, "description": desc, "workflow_config": wf}, + timeout=30, + ) + r.raise_for_status() + return r.json()["id"] + + +def _child_workflow() -> Dict[str, Any]: + return { + "nodes": [ + {"id": "start-1", "type": "start", "data": {"label": "开始"}, "position": {"x": 60, "y": 120}}, + { + "id": "code-1", + "type": "code", + "data": { + "label": "子流程代码", + "language": "python", + "code": "q = input_data.get('query', '') if isinstance(input_data, dict) else ''\nresult = {'reply': f'子流程收到: {q}', 'child_ok': True}", + }, + "position": {"x": 340, "y": 120}, + }, + {"id": "end-1", "type": "end", "data": {"label": "结束"}, "position": {"x": 620, "y": 120}}, + ], + "edges": [ + {"id": "e_start_code", "source": "start-1", "target": "code-1", "sourceHandle": "right", "targetHandle": "left"}, + {"id": "e_code_end", "source": "code-1", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"}, + ], + } + + +def _parent_workflow(child_agent_id: str) -> Dict[str, Any]: + return { + "nodes": [ + {"id": "start-1", "type": "start", "data": {"label": "开始"}, "position": {"x": 60, "y": 120}}, + { + "id": "sub-1", + "type": "subworkflow", + "data": { + "label": "调用子Agent", + "agent_id": child_agent_id, + "input_mapping": {"query": "query", "user_id": "user_id"}, + "max_subworkflow_depth": 2, + }, + "position": {"x": 340, "y": 120}, + }, + {"id": "end-1", "type": "end", "data": {"label": "结束"}, "position": {"x": 620, "y": 120}}, + ], + "edges": [ + {"id": "e_start_sub", "source": "start-1", "target": "sub-1", "sourceHandle": "right", "targetHandle": "left"}, + {"id": "e_sub_end", "source": "sub-1", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"}, + ], + } + + +def _wait_execution(h: Dict[str, str], execution_id: str, timeout_s: float = 120.0) -> Dict[str, Any]: + t0 = time.time() + while time.time() - t0 < timeout_s: + r = requests.get(f"{BASE}/api/v1/executions/{execution_id}", headers=h, timeout=30) + r.raise_for_status() + d = r.json() + st = d.get("status") + if st in ("completed", "failed"): + return d + time.sleep(1.2) + raise TimeoutError(f"执行超时: {execution_id}") + + +def main() -> int: + h = _login_headers() + + child_id = _create_or_update_agent( + h, CHILD_NAME, "E2E 子流程 Agent", _child_workflow() + ) + parent_id = _create_or_update_agent( + h, PARENT_NAME, "E2E 父流程 Agent(含 subworkflow)", _parent_workflow(child_id) + ) + + print(f"child_agent={child_id}") + print(f"parent_agent={parent_id}") + + create = requests.post( + f"{BASE}/api/v1/executions", + headers=h, + json={ + "agent_id": parent_id, + "input_data": {"query": "你好", "user_id": "e2e_sub_user"}, + }, + timeout=30, + ) + create.raise_for_status() + execution_id = create.json()["id"] + print(f"execution={execution_id}") + + done = _wait_execution(h, execution_id) + print(f"status={done.get('status')}") + if done.get("status") != "completed": + print(f"error={done.get('error_message')}") + return 2 + + chain = requests.get( + f"{BASE}/api/v1/execution-logs/executions/{execution_id}/chain", + headers=h, + timeout=30, + ) + chain.raise_for_status() + tree = chain.json() + child_count = len(tree.get("children") or []) + print(f"child_executions={child_count}") + if child_count < 1: + print("未发现子执行记录,校验失败") + return 3 + + print("E2E 通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/e2e_zhini16_hello.py b/backend/scripts/e2e_zhini16_hello.py new file mode 100644 index 0000000..58ca93c --- /dev/null +++ b/backend/scripts/e2e_zhini16_hello.py @@ -0,0 +1,124 @@ +"""E2E:知你客服16号 单轮「你好」(需 API + Celery + LLM)。 + +环境变量: + USE_TESTCLIENT=1 走 FastAPI TestClient(不经 HTTP),仅校验创建执行 201。 + API_BASE 默认 http://127.0.0.1:8037;若该端口上 /health 无 checks,可改用本仓库单独起的实例,例如 http://127.0.0.1:8040。 + E2E_AGENT_NAME / E2E_QUERY +""" +from __future__ import annotations + +import json +import os +import sys +import time +import uuid + +BACKEND_DIR = __file__.rsplit("scripts", 1)[0] +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8037") +AGENT_NAME = os.environ.get("E2E_AGENT_NAME", "知你客服16号") +QUERY = os.environ.get("E2E_QUERY", "你好") + + +def main() -> int: + os.chdir(BACKEND_DIR) + sys.path.insert(0, BACKEND_DIR) + + if os.environ.get("USE_TESTCLIENT") == "1": + from fastapi.testclient import TestClient + from app.main import app + from app.core.database import SessionLocal + from app.core.security import create_access_token + from app.models.agent import Agent + from app.models.user import User + + db = SessionLocal() + try: + agent = db.query(Agent).filter(Agent.name == AGENT_NAME).first() + if not agent: + print(f"数据库中未找到「{AGENT_NAME}」", file=sys.stderr) + return 1 + owner = db.query(User).filter(User.id == agent.user_id).first() + user = owner or db.query(User).first() + if not user: + print("无可用用户", file=sys.stderr) + return 1 + token = create_access_token(data={"sub": user.id, "username": user.username}) + h = {"Authorization": f"Bearer {token}"} + finally: + db.close() + + c = TestClient(app) + er = c.post( + "/api/v1/executions", + headers=h, + json={ + "agent_id": str(agent.id), + "input_data": {"query": QUERY, "user_id": f"e2e_z16_tc_{uuid.uuid4().hex[:8]}"}, + }, + ) + print("TestClient POST /executions:", er.status_code, er.text[:800]) + if er.status_code not in (200, 201): + return 2 + print("OK — 本仓库 API 创建执行成功(异步需 Celery Worker 才会跑完)") + return 0 + + import httpx + from app.core.database import SessionLocal + from app.core.security import create_access_token + from app.models.agent import Agent + from app.models.user import User + + db = SessionLocal() + try: + agent = db.query(Agent).filter(Agent.name == AGENT_NAME).first() + if not agent: + print(f"数据库中未找到「{AGENT_NAME}」", file=sys.stderr) + return 1 + owner = db.query(User).filter(User.id == agent.user_id).first() + user = owner or db.query(User).first() + if not user: + print("无可用用户", file=sys.stderr) + return 1 + token = create_access_token(data={"sub": user.id, "username": user.username}) + headers = {"Authorization": f"Bearer {token}"} + uid = f"e2e_z16_{uuid.uuid4().hex[:10]}" + print(f"agent_id={agent.id} name={agent.name} Q={QUERY!r} user_id={uid}\n") + + def poll(client: httpx.Client, execution_id: str, timeout: float = 300.0) -> dict: + t0 = time.time() + while time.time() - t0 < timeout: + r = client.get(f"/api/v1/executions/{execution_id}", headers=headers) + r.raise_for_status() + data = r.json() + st = data.get("status") + if st == "completed": + return data + if st == "failed": + print("error:", data.get("error_message"), file=sys.stderr) + raise RuntimeError("执行失败") + time.sleep(1) + raise TimeoutError("超时") + + with httpx.Client(base_url=API_BASE, timeout=300.0) as client: + r = client.post( + "/api/v1/executions", + json={"agent_id": str(agent.id), "input_data": {"query": QUERY, "user_id": uid}}, + headers=headers, + ) + if r.status_code >= 400: + print(r.text, file=sys.stderr) + r.raise_for_status() + eid = r.json()["id"] + out = poll(client, eid) + od = out.get("output_data") or {} + rtxt = od.get("result") if isinstance(od, dict) else None + print("--- 回复 ---") + print((rtxt or json.dumps(od, ensure_ascii=False))[:4000]) + finally: + db.close() + print("\n完成") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/restart_api_worker.ps1 b/backend/scripts/restart_api_worker.ps1 index 19eb679..a1b00ec 100644 --- a/backend/scripts/restart_api_worker.ps1 +++ b/backend/scripts/restart_api_worker.ps1 @@ -15,12 +15,50 @@ Get-CimInstance Win32_Process | Where-Object { Stop-Process -Id $_.ProcessId -Force } +# 兜底:清理仍占用 8037 的监听进程(多实例会随机命中旧代码并出现 500 / 路由不一致) +for ($round = 0; $round -lt 8; $round++) { + $pids = @() + try { + $pids = @(Get-NetTCPConnection -LocalPort 8037 -State Listen -ErrorAction SilentlyContinue | + ForEach-Object { $_.OwningProcess } | Where-Object { $_ -and $_ -gt 0 } | Sort-Object -Unique) + } catch {} + if (-not $pids -or $pids.Count -eq 0) { break } + foreach ($proc in $pids) { + Write-Host "Stop port 8037 PID $proc" + Stop-Process -Id ([int]$proc) -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Milliseconds 600 +} +netstat -ano 2>$null | Select-String ":8037\s+.*LISTENING" | ForEach-Object { + $parts = ($_.Line -replace '\s+', ' ').Trim() -split ' ' + $lpid = $parts[-1] + if ($lpid -match '^\d+$' -and [int]$lpid -gt 0) { + Write-Host "Stop port 8037 (netstat) PID $lpid" + Stop-Process -Id ([int]$lpid) -Force -ErrorAction SilentlyContinue + } +} + Start-Sleep -Seconds 2 $py = Join-Path $backend "venv\Scripts\python.exe" -Write-Host "Start Uvicorn :8037 ..." +if ($env:SKIP_ALEMBIC -ne "1") { + Write-Host "alembic upgrade head ..." + Push-Location $backend + try { + & $py -m alembic upgrade head 2>&1 | ForEach-Object { Write-Host $_ } + } catch { + Write-Host "alembic failed: $($_.Exception.Message)" + } finally { + Pop-Location + } +} else { + Write-Host "SKIP_ALEMBIC=1 - skipped alembic." +} + +# 不使用 --reload:Windows 上 reload 会多进程且多次重启易残留多个 8037 监听,导致随机命中旧实例、POST 执行 500 +Write-Host "Start Uvicorn :8037 (no --reload) ..." Start-Process -FilePath $py -ArgumentList @( - "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8037", "--reload" + "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8037" ) -WorkingDirectory $backend -WindowStyle Minimized Start-Sleep -Seconds 2 @@ -35,6 +73,15 @@ Start-Sleep -Seconds 3 try { $r = Invoke-WebRequest -Uri "http://127.0.0.1:8037/health" -UseBasicParsing -TimeoutSec 15 Write-Host "health: $($r.Content)" + $j = $r.Content | ConvertFrom-Json + $names = @($j.PSObject.Properties.Name) + if (-not ($names -contains "checks")) { + Write-Host "" + Write-Host "[WARN] /health has no 'checks' field; port 8037 may not be this repo API." + Write-Host "Kill stray python/uvicorn, then: uvicorn app.main:app --host 0.0.0.0 --port 8037" + Write-Host "Or use port 8040 and set AIAGENT_API_PROXY for npm run dev (see vite.config.ts)." + Write-Host "" + } } catch { Write-Host "health check failed: $($_.Exception.Message)" } diff --git a/backend/scripts/templates/template_customer_service.py b/backend/scripts/templates/template_customer_service.py new file mode 100644 index 0000000..8d29fb5 --- /dev/null +++ b/backend/scripts/templates/template_customer_service.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +路线图场景模板:客服 — 通过 API 从模板创建 Agent。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/templates/template_customer_service.py + +环境变量: PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD, + AGENT_NAME(默认 场景模板_客服_时间戳 可不用), TARGET_NAME +""" +from __future__ import annotations + +import os +import sys +import time + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +TEMPLATE_ID = "template_customer_service" +DEFAULT_NAME = os.getenv("TARGET_NAME") or f"场景模板_客服_{int(time.time())}" + + +def main() -> int: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if r.status_code != 200: + print("登录失败:", r.status_code, r.text[:500], file=sys.stderr) + return 1 + token = r.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + body = { + "template_id": TEMPLATE_ID, + "name": DEFAULT_NAME, + "description": "由 scripts/templates/template_customer_service.py 创建", + "parameters": {"temperature": 0.35}, + } + cr = requests.post(f"{BASE}/api/v1/agents/from-scene-template", json=body, headers=h, timeout=60) + if cr.status_code not in (200, 201): + print("创建失败:", cr.status_code, cr.text[:800], file=sys.stderr) + return 1 + print(cr.json().get("id"), cr.json().get("name")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/templates/template_dev_codegen.py b/backend/scripts/templates/template_dev_codegen.py new file mode 100644 index 0000000..0e89a50 --- /dev/null +++ b/backend/scripts/templates/template_dev_codegen.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +路线图场景模板:研发 / 代码助手 — 通过 API 从模板创建 Agent。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/templates/template_dev_codegen.py +""" +from __future__ import annotations + +import os +import sys +import time + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +TEMPLATE_ID = "template_dev_codegen" +DEFAULT_NAME = os.getenv("TARGET_NAME") or f"场景模板_研发_{int(time.time())}" + + +def main() -> int: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if r.status_code != 200: + print("登录失败:", r.status_code, r.text[:500], file=sys.stderr) + return 1 + token = r.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + body = { + "template_id": TEMPLATE_ID, + "name": DEFAULT_NAME, + "description": "由 scripts/templates/template_dev_codegen.py 创建", + "parameters": {"preferred_language": "Python", "temperature": 0.25}, + } + cr = requests.post(f"{BASE}/api/v1/agents/from-scene-template", json=body, headers=h, timeout=60) + if cr.status_code not in (200, 201): + print("创建失败:", cr.status_code, cr.text[:800], file=sys.stderr) + return 1 + print(cr.json().get("id"), cr.json().get("name")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/templates/template_ops_log_analysis.py b/backend/scripts/templates/template_ops_log_analysis.py new file mode 100644 index 0000000..9b80152 --- /dev/null +++ b/backend/scripts/templates/template_ops_log_analysis.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +路线图场景模板:运维 / 日志分析 — 通过 API 从模板创建 Agent。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/templates/template_ops_log_analysis.py +""" +from __future__ import annotations + +import os +import sys +import time + +import requests + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +TEMPLATE_ID = "template_ops_log_analysis" +DEFAULT_NAME = os.getenv("TARGET_NAME") or f"场景模板_运维_{int(time.time())}" + + +def main() -> int: + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if r.status_code != 200: + print("登录失败:", r.status_code, r.text[:500], file=sys.stderr) + return 1 + token = r.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + body = { + "template_id": TEMPLATE_ID, + "name": DEFAULT_NAME, + "description": "由 scripts/templates/template_ops_log_analysis.py 创建", + "parameters": {"temperature": 0.3}, + } + cr = requests.post(f"{BASE}/api/v1/agents/from-scene-template", json=body, headers=h, timeout=60) + if cr.status_code not in (200, 201): + print("创建失败:", cr.status_code, cr.text[:800], file=sys.stderr) + return 1 + print(cr.json().get("id"), cr.json().get("name")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 53c894f..ba3e0ed 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -5,20 +5,26 @@ import router from '@/router' // 获取API基础URL const getApiBaseURL = () => { - // 如果在浏览器中,优先根据当前主机自动推断(避免从公网访问localhost的问题) + // 浏览器内:开发模式优先走 Vite 代理(vite.config.ts 将 /api 转到 8037),避免直连 8037 命中多实例/陈旧 Uvicorn 导致 500 if (typeof window !== 'undefined') { + if (import.meta.env.DEV) { + if (import.meta.env.VITE_API_URL) { + console.log('[API] DEV 使用 VITE_API_URL:', import.meta.env.VITE_API_URL) + return import.meta.env.VITE_API_URL + } + console.log('[API] DEV 走当前站点 + Vite 代理 /api → localhost:8037(勿直连 8037)') + return '' + } + const hostname = window.location.hostname const protocol = window.location.protocol - - // 如果是localhost或127.0.0.1,使用localhost:8037 + if (hostname === 'localhost' || hostname === '127.0.0.1') { const apiUrl = 'http://localhost:8037' console.log('[API] 使用本地API地址:', apiUrl) return apiUrl } - - // 对于公网IP,必须使用相同的IP地址,不能使用localhost - // 使用相同主机名,端口8037 + const apiUrl = `${protocol}//${hostname}:8037` console.log('[API] 自动检测API地址:', apiUrl, '(当前主机:', hostname, ')') return apiUrl @@ -94,9 +100,22 @@ api.interceptors.response.use( return Promise.reject(error) } - // 处理500服务器错误 + // 503:多为 Redis/Celery 不可用(FastAPI HTTPException 使用 detail) + if (status === 503) { + const message = + (typeof data?.detail === 'string' ? data.detail : null) || + data?.message || + '服务暂时不可用(请检查 Redis 与 Celery Worker)' + ElMessage.error(message) + return Promise.reject(error) + } + + // 处理500服务器错误(DATABASE_ERROR 的 message 由后端区分「缺列/迁移」等;DEBUG 时可有 detail) if (status === 500) { - const message = data?.message || '服务器内部错误,请稍后重试' + const message = + (typeof data?.detail === 'string' ? data.detail : null) || + data?.message || + '服务器内部错误,请稍后重试' ElMessage.error(message) console.error('服务器错误:', data) return Promise.reject(error) diff --git a/frontend/src/components/AgentChatPreview.vue b/frontend/src/components/AgentChatPreview.vue index 918e219..c3bd174 100644 --- a/frontend/src/components/AgentChatPreview.vue +++ b/frontend/src/components/AgentChatPreview.vue @@ -181,6 +181,24 @@ const messagesContainer = ref() let pollingInterval: any = null let replyAdded = false // 标志位:防止重复添加回复 +/** 会话记忆需稳定 user_id(见 agent记忆实现方案.md);预览区按 Agent 维度持久化,对应 Cache 键 user_memory_* */ +function getPreviewSessionUserId(agentId: string): string { + const key = `agent_preview_uid_${agentId}` + try { + let id = localStorage.getItem(key) + if (!id) { + id = + typeof crypto !== 'undefined' && crypto.randomUUID + ? `preview_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + : `preview_${Date.now()}_${Math.random().toString(36).slice(2, 12)}` + localStorage.setItem(key, id) + } + return id + } catch { + return `preview_${agentId}_${Date.now()}` + } +} + // 发送消息 const handleSendMessage = async () => { if (!inputMessage.value.trim() || loading.value || !props.agentId) return @@ -205,7 +223,8 @@ const handleSendMessage = async () => { agent_id: props.agentId, input_data: { USER_INPUT: userMessage, - query: userMessage + query: userMessage, + user_id: getPreviewSessionUserId(props.agentId) } }) diff --git a/frontend/src/components/MainLayout.vue b/frontend/src/components/MainLayout.vue index 7b62e42..b529434 100644 --- a/frontend/src/components/MainLayout.vue +++ b/frontend/src/components/MainLayout.vue @@ -19,6 +19,10 @@ :ellipsis="false" @select="handleMenuSelect" > + + + 主控台 + 工作流管理 @@ -76,7 +80,7 @@ import { computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useUserStore } from '@/stores/user' -import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell } from '@element-plus/icons-vue' +import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid } from '@element-plus/icons-vue' const router = useRouter() const route = useRoute() @@ -84,6 +88,8 @@ const userStore = useUserStore() // 当前激活的菜单 const activeMenu = computed(() => { + if (route.path === '/console') return 'console' + if (route.path === '/execution-board') return 'console' if (route.path === '/' || route.path === '/workflow' || route.path.startsWith('/workflow/')) return 'workflows' if (route.path === '/agents' || route.path.startsWith('/agents/')) return 'agents' if (route.path === '/executions' || route.path.startsWith('/executions/')) return 'executions' @@ -99,7 +105,9 @@ const activeMenu = computed(() => { // 菜单选择 const handleMenuSelect = (key: string) => { - if (key === 'workflows') { + if (key === 'console') { + router.push('/console') + } else if (key === 'workflows') { router.push('/') } else if (key === 'agents') { router.push('/agents') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d801830..0f84242 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -16,6 +16,18 @@ const router = createRouter({ component: () => import('@/views/Home.vue'), meta: { requiresAuth: true } }, + { + path: '/console', + name: 'main-console', + component: () => import('@/views/MainConsole.vue'), + meta: { requiresAuth: true } + }, + { + path: '/execution-board', + name: 'execution-board', + component: () => import('@/views/ExecutionBoard.vue'), + meta: { requiresAuth: true } + }, { path: '/workflow/:id?', name: 'workflow', diff --git a/frontend/src/stores/agent.ts b/frontend/src/stores/agent.ts index ac67a06..61e1b6f 100644 --- a/frontend/src/stores/agent.ts +++ b/frontend/src/stores/agent.ts @@ -14,6 +14,7 @@ export interface Agent { nodes: WorkflowNode[] edges: WorkflowEdge[] } + budget_config?: Record | null version: number status: string user_id: string @@ -50,6 +51,33 @@ export const useAgentStore = defineStore('agent', () => { } // 创建Agent + const fetchSceneTemplates = async () => { + const response = await api.get('/api/v1/platform/scene-templates') + return response.data as Array<{ + id: string + title: string + description: string + category?: string + }> + } + + const createFromSceneTemplate = async (body: { + template_id: string + name: string + description?: string + parameters?: Record + budget_config?: Record + }) => { + loading.value = true + try { + const response = await api.post('/api/v1/platform/agents/from-template', body) + agents.value.unshift(response.data) + return response.data + } finally { + loading.value = false + } + } + const createAgent = async (agentData: { name: string description?: string @@ -57,6 +85,7 @@ export const useAgentStore = defineStore('agent', () => { nodes: WorkflowNode[] edges: WorkflowEdge[] } + budget_config?: Record }) => { loading.value = true try { @@ -226,6 +255,8 @@ export const useAgentStore = defineStore('agent', () => { currentAgent, loading, fetchAgents, + fetchSceneTemplates, + createFromSceneTemplate, createAgent, fetchAgent, updateAgent, diff --git a/frontend/src/stores/execution.ts b/frontend/src/stores/execution.ts index 981375e..5f8061a 100644 --- a/frontend/src/stores/execution.ts +++ b/frontend/src/stores/execution.ts @@ -15,6 +15,9 @@ export interface Execution { error_message?: string execution_time?: number task_id?: string + parent_execution_id?: string + depth?: number + pause_state?: Record | null created_at: string } diff --git a/frontend/src/views/Agents.vue b/frontend/src/views/Agents.vue index 689e826..cfe19c4 100644 --- a/frontend/src/views/Agents.vue +++ b/frontend/src/views/Agents.vue @@ -10,6 +10,9 @@ 导入Agent + + 从场景模板创建 + 创建Agent @@ -261,6 +264,55 @@ + + + + + + + + + + + + + + + 可选执行预算(留空则用平台默认) + + + + + + + + + + + + + >([]) +const tplForm = ref({ + template_id: '', + name: '', + description: '', + max_steps: undefined as number | undefined, + max_llm_invocations: undefined as number | undefined, + max_tool_calls: undefined as number | undefined +}) + +function resetTemplateForm() { + tplForm.value = { + template_id: '', + name: '', + description: '', + max_steps: undefined, + max_llm_invocations: undefined, + max_tool_calls: undefined + } +} + +async function openTemplateDialog() { + resetTemplateForm() + templateDialogVisible.value = true + try { + sceneTemplates.value = await agentStore.fetchSceneTemplates() + if (sceneTemplates.value.length && !tplForm.value.template_id) { + tplForm.value.template_id = sceneTemplates.value[0].id + } + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '加载模板列表失败') + } +} + +async function submitTemplateCreate() { + const name = tplForm.value.name.trim() + if (!tplForm.value.template_id) { + ElMessage.warning('请选择场景模板') + return + } + if (!name) { + ElMessage.warning('请输入名称') + return + } + const bc: Record = {} + if (tplForm.value.max_steps != null && tplForm.value.max_steps > 0) { + bc.max_steps = tplForm.value.max_steps + } + if (tplForm.value.max_llm_invocations != null && tplForm.value.max_llm_invocations > 0) { + bc.max_llm_invocations = tplForm.value.max_llm_invocations + } + if (tplForm.value.max_tool_calls != null && tplForm.value.max_tool_calls > 0) { + bc.max_tool_calls = tplForm.value.max_tool_calls + } + templateSubmitting.value = true + try { + await agentStore.createFromSceneTemplate({ + template_id: tplForm.value.template_id, + name, + description: tplForm.value.description?.trim() || undefined, + parameters: {}, + budget_config: Object.keys(bc).length ? bc : undefined + }) + ElMessage.success('已从模板创建 Agent') + templateDialogVisible.value = false + await loadAgents() + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '创建失败') + } finally { + templateSubmitting.value = false + } +} + // 搜索和筛选 const searchText = ref('') const statusFilter = ref('') diff --git a/frontend/src/views/ExecutionBoard.vue b/frontend/src/views/ExecutionBoard.vue new file mode 100644 index 0000000..ddfa825 --- /dev/null +++ b/frontend/src/views/ExecutionBoard.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/frontend/src/views/ExecutionDetail.vue b/frontend/src/views/ExecutionDetail.vue index bc9f976..dedb931 100644 --- a/frontend/src/views/ExecutionDetail.vue +++ b/frontend/src/views/ExecutionDetail.vue @@ -77,7 +77,38 @@ :closable="false" /> - + + + + + +

执行在审批节点挂起,提交后将由后台任务恢复运行。

+ + + 通过 + + + 拒绝 +
@@ -409,6 +440,8 @@ const logFilter = ref({ node_id: '' }) const nodeList = ref([]) +const hilComment = ref('') +const resumeLoading = ref(false) const router = useRouter() const route = useRoute() @@ -481,6 +514,24 @@ const loadExecution = async (id: string) => { } } +const doResume = async (decision: 'approved' | 'rejected') => { + if (!executionStore.currentExecution) return + resumeLoading.value = true + try { + await api.post(`/api/v1/executions/${executionStore.currentExecution.id}/resume`, { + decision, + comment: hilComment.value || undefined + }) + ElMessage.success('已提交审批,任务已排队执行') + await loadExecution(executionStore.currentExecution.id) + hilComment.value = '' + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '恢复执行失败') + } finally { + resumeLoading.value = false + } +} + const handleBack = () => { router.push('/executions') } @@ -494,7 +545,8 @@ const getStatusType = (status: string) => { pending: 'info', running: 'warning', completed: 'success', - failed: 'danger' + failed: 'danger', + awaiting_approval: 'warning' } return map[status] || 'info' } @@ -504,7 +556,8 @@ const getStatusText = (status: string) => { pending: '等待中', running: '执行中', completed: '已完成', - failed: '失败' + failed: '失败', + awaiting_approval: '待审批' } return map[status] || status } @@ -807,6 +860,12 @@ watch(() => executionStore.currentExecution?.status, (newStatus) => { height: 100vh; } +.hil-hint { + color: var(--el-text-color-secondary); + margin: 0 0 12px; + font-size: 14px; +} + .el-header { background-color: #409eff; color: white; diff --git a/frontend/src/views/Executions.vue b/frontend/src/views/Executions.vue index 0e44728..5243ff3 100644 --- a/frontend/src/views/Executions.vue +++ b/frontend/src/views/Executions.vue @@ -43,6 +43,7 @@ + @@ -195,7 +196,8 @@ const getStatusType = (status: string) => { pending: 'info', running: 'warning', completed: 'success', - failed: 'danger' + failed: 'danger', + awaiting_approval: 'warning' } return map[status] || 'info' } @@ -205,7 +207,8 @@ const getStatusText = (status: string) => { pending: '等待中', running: '执行中', completed: '已完成', - failed: '失败' + failed: '失败', + awaiting_approval: '待审批' } return map[status] || status } diff --git a/frontend/src/views/MainConsole.vue b/frontend/src/views/MainConsole.vue new file mode 100644 index 0000000..2a87597 --- /dev/null +++ b/frontend/src/views/MainConsole.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 471c195..ffba068 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,7 +14,9 @@ export default defineConfig({ port: 3001, proxy: { '/api': { - target: 'http://localhost:8037', + // 8037 若被旧实例/其它程序占用(/health 无 checks),可设环境变量指向本仓库 API,例如: + // PowerShell: $env:AIAGENT_API_PROXY='http://127.0.0.1:8040'; npm run dev + target: process.env.AIAGENT_API_PROXY || 'http://127.0.0.1:8037', changeOrigin: true } } diff --git a/multi_route_workflow.json b/multi_route_workflow.json new file mode 100644 index 0000000..69b9813 --- /dev/null +++ b/multi_route_workflow.json @@ -0,0 +1,196 @@ +{ + "name": "企业多线路由助手", + "description": "根据query内容自动路由到客服/研发/运维专精LLM,合并结果输出", + "nodes": [ + { + "id": "start-1", + "type": "start", + "position": { "x": 100, "y": 250 }, + "data": { + "label": "开始", + "description": "工作流开始节点" + } + }, + { + "id": "input-1", + "type": "input", + "position": { "x": 300, "y": 250 }, + "data": { + "label": "输入查询", + "field": "query", + "description": "输入用户查询内容", + "required": true, + "placeholder": "请输入您的问题..." + } + }, + { + "id": "switch-1", + "type": "switch", + "position": { "x": 500, "y": 250 }, + "data": { + "label": "路由判断", + "field": "query", + "cases": { + "客服": "customer_service", + "研发": "development", + "运维": "operation" + }, + "default": "customer_service", + "description": "根据query内容路由到不同专精LLM" + } + }, + { + "id": "llm-customer", + "type": "llm", + "position": { "x": 700, "y": 150 }, + "data": { + "label": "客服专精LLM", + "model": "deepseek", + "prompt": "你是一个专业的客服助手,擅长处理客户咨询、投诉、售后问题。请根据用户问题提供专业、友好的客服解答。\n\n用户问题:{{query}}\n\n请回答:", + "temperature": 0.7, + "max_tokens": 1000, + "enable_tools": true, + "description": "客服领域专精AI助手" + } + }, + { + "id": "llm-development", + "type": "llm", + "position": { "x": 700, "y": 250 }, + "data": { + "label": "研发专精LLM", + "model": "deepseek", + "prompt": "你是一个专业的研发工程师,擅长技术问题解答、代码调试、架构设计。请根据用户问题提供专业的技术解决方案。\n\n用户问题:{{query}}\n\n请回答:", + "temperature": 0.7, + "max_tokens": 1000, + "enable_tools": true, + "description": "研发技术专精AI助手" + } + }, + { + "id": "llm-operation", + "type": "llm", + "position": { "x": 700, "y": 350 }, + "data": { + "label": "运维专精LLM", + "model": "deepseek", + "prompt": "你是一个专业的运维工程师,擅长系统部署、监控、故障排查。请根据用户问题提供专业的运维解决方案。\n\n用户问题:{{query}}\n\n请回答:", + "temperature": 0.7, + "max_tokens": 1000, + "enable_tools": true, + "description": "运维领域专精AI助手" + } + }, + { + "id": "merge-1", + "type": "merge", + "position": { "x": 900, "y": 250 }, + "data": { + "label": "结果合并", + "mode": "merge_all", + "strategy": "object", + "description": "合并所有分支结果(实际上只有一个分支会执行)" + } + }, + { + "id": "output-1", + "type": "output", + "position": { "x": 1100, "y": 250 }, + "data": { + "label": "输出结果", + "field": "response", + "description": "输出最终回答" + } + }, + { + "id": "end-1", + "type": "end", + "position": { "x": 1300, "y": 250 }, + "data": { + "label": "结束", + "description": "工作流结束" + } + } + ], + "edges": [ + { + "id": "e1", + "source": "start-1", + "target": "input-1", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e2", + "source": "input-1", + "target": "switch-1", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e3-customer", + "source": "switch-1", + "target": "llm-customer", + "sourceHandle": "right", + "targetHandle": "left", + "data": { + "branch": "customer_service" + } + }, + { + "id": "e3-development", + "source": "switch-1", + "target": "llm-development", + "sourceHandle": "right", + "targetHandle": "left", + "data": { + "branch": "development" + } + }, + { + "id": "e3-operation", + "source": "switch-1", + "target": "llm-operation", + "sourceHandle": "right", + "targetHandle": "left", + "data": { + "branch": "operation" + } + }, + { + "id": "e4-customer", + "source": "llm-customer", + "target": "merge-1", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e4-development", + "source": "llm-development", + "target": "merge-1", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e4-operation", + "source": "llm-operation", + "target": "merge-1", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e5", + "source": "merge-1", + "target": "output-1", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e6", + "source": "output-1", + "target": "end-1", + "sourceHandle": "right", + "targetHandle": "left" + } + ] +} \ No newline at end of file diff --git a/上传git仓.md b/上传git仓.md index e5c37ba..a2da3a8 100644 --- a/上传git仓.md +++ b/上传git仓.md @@ -1,2 +1,10 @@ -将修改上传到git仓rjb_win_dev分支 -http://101.43.95.130:3001/admin/aiagent.git \ No newline at end of file +将修改上传到 git 仓 **rjb_win_dev** 分支: + +http://101.43.95.130:3001/admin/aiagent.git + +```powershell +cd D:\aaa\aiagent +git push -u origin rjb_win_dev +``` + +鉴权请使用本机已保存的凭据,或在提示时输入平台账号;**勿在文档中保存明文密码**,也勿将含密码的文件提交到仓库。 diff --git a/可执行的 90 天演进路线图.md b/可执行的 90 天演进路线图.md new file mode 100644 index 0000000..bf17763 --- /dev/null +++ b/可执行的 90 天演进路线图.md @@ -0,0 +1,245 @@ +# 可执行的 90 天演进路线图 + +目标:在不推翻现有平台的前提下,演进为“可编排 + 可编程 + 可治理”的企业 Agent 平台。 +原则:保留现有优势(可视化流程、权限体系、执行日志/审计),做渐进增强。 + +--- + +## 一、90 天总目标(验收口径) + +- 支持 **Main Agent -> 子 Agent** 的真实委派执行(非占位)。 +- 支持 **模板化创建场景 Agent**(复制模板 + 参数注入)。 +- 支持 **治理能力**(预算、审计、告警、人工确认)。 +- 前端提供可用的 **主控台入口**(路由、执行、追踪)。 + +--- + +## 二、阶段拆解 + +## 阶段 1(Day 1-30):稳态自动化夯实 + +### 1) 子工作流真实执行(最小闭环) + +**目标** +- 将 `subworkflow` 从占位实现升级为真实执行能力。 + +**代码改动** +- `backend/app/services/workflow_engine.py` + - 实现 `subworkflow` 节点真实执行: + - 读取目标 workflow/agent 配置。 + - 构造并执行子 `WorkflowEngine`。 + - 返回结构化 `output/status/error`。 + - 增加防护参数:`max_subworkflow_depth`(默认 2)。 +- `backend/app/tasks/workflow_tasks.py` + - 透传嵌套执行上下文(`parent_execution_id`、`depth`)。 +- `backend/app/api/executions.py` + - 扩展执行输出,保留父子执行关联字段。 + +**验收** +- 一个 Agent 可在流程内调用另一个 Agent/Workflow 并拿到结果。 +- 执行日志可追踪父子链路。 + +--- + +### 2) 16 号稳定性与模板规范化 + +**目标** +- 让 Loop 多段执行在生产可稳定运行并可复制。 + +**代码/文档改动** +- `backend/scripts/create_zhini_kefu_16.py` + - 固化 loop 数据路径与 merge 行为。 + - 支持 2 段/3 段模式开关。 +- `知你客服16号能力文档.md` + - 增加“已知边界、推荐场景、故障排查”章节。 +- `知你客服15号_循环工作流设计.md` + - 增补“跨轮状态传递策略”(文件接力/Cache接力)。 + +**验收** +- 连续 20 次回归无 `loop_rounds` 空值类错误。 +- 常见问法稳定返回,不再出现异常回显。 + +--- + +### 3) 观测与失败重试 + +**目标** +- 提高运行可观测性与容错能力。 + +**代码改动** +- `backend/app/services/execution_logger.py` + - 增加统一错误码(例如 `SUBFLOW_DEPTH_EXCEEDED`)。 +- `backend/app/tasks/workflow_tasks.py` + - 增加可配置的退避重试(1-2 次)。 +- `backend/app/api/execution_logs.py` + - 增加 parent/child 聚合查询接口。 + +**验收** +- 失败能定位到“哪一层、哪个节点、哪次子调用”。 +- 重试行为可追踪可审计。 + +--- + +## 阶段 2(Day 31-60):多 Agent 协作 + +### 4) Main Agent 路由协议(结构化输出) + +**目标** +- 主入口 Agent 负责任务分解与路由决策。 + +**代码改动** +- 新增:`backend/scripts/create_main_agent.py` + - 创建主入口 Agent(仅输出结构化路由结果)。 +- `backend/app/services/workflow_engine.py` + - 增加 `invoke_agent` 节点(推荐)或等价工具调用。 +- `backend/app/services/tool_registry.py` / `backend/app/services/builtin_tools.py` + - 若走工具路线,注册 `invoke_agent` 工具与 schema。 + +**验收** +- 用户一次输入,主 Agent 完成路由并触发子 Agent 执行。 +- 子执行结果可被主流程汇总输出。 + +--- + +### 5) 场景模板库(客服/研发/运维/数据) + +**目标** +- 形成可复用模板,降低搭建门槛。 + +**代码改动** +- 新增目录:`backend/scripts/templates/` + - `template_customer_service.py` + - `template_dev_codegen.py` + - `template_ops_log_analysis.py` +- `backend/app/api/agents.py` + - 增加“从模板创建 Agent”接口(可选)。 +- 前端模板入口(视项目结构放到 Agent 创建页对应组件) + - 增加模板选择 + 参数表单。 + +**验收** +- 至少 3 个模板可一键创建并执行。 +- 模板参数(工具白名单、段数、预算)可配置。 + +--- + +### 6) Human-in-the-loop(关键动作确认) + +**目标** +- 将危险操作纳入审批闭环。 + +**代码改动** +- `backend/app/services/workflow_engine.py` + - 增加 `approval` 节点,或在节点 data 中支持 `requires_approval`。 +- `backend/app/api/executions.py` + - 增加“挂起等待审批 / 审批通过继续执行”接口。 +- 前端执行页面 + - 增加审批状态展示与操作入口。 + +**验收** +- 高风险节点默认挂起,审批通过后继续。 +- 审批日志可追溯。 + +--- + +## 阶段 3(Day 61-90):治理与平台化 + +### 7) 成本、预算与并发治理 + +**目标** +- 防止执行失控,支持组织级运营。 + +**代码改动** +- `backend/app/services/workflow_engine.py` + - 增加预算控制:`max_steps`、工具调用上限、token 估算上限。 +- `backend/app/models/`(新增预算配置模型) + - 按租户/项目/Agent 维度配置预算。 +- `backend/app/api/monitoring.py` / `backend/app/api/alert_rules.py` + - 增加预算超限告警与熔断策略。 + +**验收** +- 可按 Agent 设置预算阈值并生效。 +- 超限自动终止并记录可读原因。 + +--- + +### 8) 统一 DSL(场景可编程输入) + +**目标** +- 让不同模板复用统一输入契约。 + +**代码改动** +- 新增:`backend/app/services/scenario_dsl.py` + - 定义标准输入:目标、约束、产物、验收标准。 +- `backend/app/services/workflow_engine.py` + - 在入口处做 DSL 校验与标准化映射。 +- 模板脚本 + - 全部迁移到 DSL 输入。 + +**验收** +- 同一输入可驱动多模板运行。 +- 场景迁移与复用成本显著下降。 + +--- + +### 9) 主控台(应用商店 + 运行看板) + +**目标** +- 降低业务用户使用门槛,强化平台可运营性。 + +**前端改动(按现有结构落地)** +- 新增页面: + - `MainConsole`(主入口) + - `TemplateMarket`(模板市场) + - `ExecutionBoard`(父子执行链看板) +- 新增 API 封装: + - 模板创建、子执行追踪、审批操作。 + +**验收** +- 业务用户流程:选模板 -> 填参数 -> 执行 -> 看结果。 +- 管理员可查看预算、告警、审计。 + +--- + +## 三、每周里程碑(12 周) + +- W1-W2:`subworkflow` 真执行 + 深度防护。 +- W3-W4:日志链路 + 16 号稳定性回归。 +- W5-W6:Main Agent 路由 + `invoke_agent`。 +- W7-W8:模板库 + 审批节点。 +- W9-W10:预算治理 + DSL。 +- W11-W12:主控台 + UAT + 发布。 + +--- + +## 四、90 天成功指标(建议) + +- 模板复用率 > 60%。 +- 核心场景自动化成功率 > 85%。 +- 平均处理时长下降 >= 30%。 +- 可审计执行覆盖率 = 100%。 +- 高风险动作审批覆盖率 = 100%。 + +--- + +## 五、本周可立刻开工的 3 件事 + +1. 完成 `subworkflow` 真执行(`workflow_engine.py`)。 +2. 定义 Main Agent 的路由 JSON 协议(先后端可消费)。 +3. 上线 3 个模板脚本(客服/研发/运维)并打通端到端回归。 + +--- + +## 六、风险与规避 + +- 风险:多 Agent 递归调用导致资源爆炸。 + 规避:`max_subworkflow_depth` + `max_steps` + 预算熔断。 + +- 风险:模型动态创建 workflow JSON 质量不稳定。 + 规避:优先“模板复制 + 参数注入”,减少从零生成。 + +- 风险:工具权限与合规风险。 + 规避:工具白名单、审批节点、审计留痕。 + +--- + +> 说明:本路线图基于当前仓库已有能力(工作流引擎、Agent API、执行任务、日志与权限)制定,采用“可交付优先”的渐进式演进策略。 diff --git a/知你客服15号_循环工作流设计.md b/知你客服15号_循环工作流设计.md new file mode 100644 index 0000000..331b475 --- /dev/null +++ b/知你客服15号_循环工作流设计.md @@ -0,0 +1,142 @@ +# 知你客服 15 号 · Loop + 多轮 LLM 工作流设计(可落地版) + +本文档说明如何在**同一次 API 调用**内,用 **循环(loop/foreach)节点**驱动「多轮」LLM 执行,并与现有 **知你主线**(Start → Cache → `llm-unified` → Cache → End)衔接;同时标明引擎行为边界与推荐降级方案。 + +--- + +## 1. 目标与两种能力层级 + +| 层级 | 做法 | 适用场景 | +|------|------|----------| +| **A. 单节点多轮工具** | 在 `llm-unified` 的 `data` 中配置 `max_tool_iterations`(如 28),一次进入 LLM 节点内多轮 tool 调用 | 工具链较长、但**不需要**按「外部数组」拆成多次独立推理 | +| **B. 循环体多轮 LLM** | 在图中增加 **loop**,循环体里放 **专用 LLM 子节点**,对 `items` 数组**每一项**跑一遍模型(可带不同 `loop_input`) | 需要「子任务列表 / 分步计划 / 固定 K 轮」等**显式多段**执行 | + +15 号线已具备 **A**;若产品明确要求「画布上多次跑 LLM」,按 **B** 设计。 + +--- + +## 2. 与知你主线的衔接(推荐拓扑) + +在**不破坏**原有缓存与记忆的前提下,典型插入方式为: + +``` +Start → Cache(读) → [准备循环数组] → Loop → [合并/写缓存] → End + ↑ ↑ + Transform/Code 循环体:LLM + 可选 Cache +``` + +- **准备循环数组**:用 **Transform** 或 **Code** 节点,在上游输出上增加一个 **数组字段**(见下节),供 loop 的 `items_path` 读取。 +- **合并**:loop 的输出是**数组**;若 End 或下游 Cache 需要**单条字符串回复**,在 Loop 后增加 **Transform**,把数组折叠成 `reply` / `right` 等字段。 + +> **不要**把原来的 `llm-unified` 同时当作「主答复」和「循环体里唯一 LLM」复用同一套 End 依赖,除非你已经用 Transform 明确产出最终 `reply`。 + +--- + +## 3. 驱动循环的数据:`items_path` 与数组形态 + +引擎从**进入 loop 节点时的输入**中,用 `items_path`(默认 `items`)取数组;若不是 `list` 会报错。 + +**示例(简单 K 轮)** +上游 Transform 产出: + +```json +{ + "query": "...", + "loop_rounds": [1, 2, 3] +} +``` + +loop 节点配置: + +- `items_path`: `loop_rounds` +- `item_variable`: `round`(则循环体内还有 `round_index`、`round_total`) + +**示例(子任务列表)** +`iteration_plan: [ {"id":"s1","desc":"..."}, ... ]`,`items_path` 填 `iteration_plan`,`item_variable` 填 `step`,提示词里用 `{{step}}` 或 `{step}` 引用(与现有 LLM 占位符规则一致)。 + +--- + +## 4. 循环体内:如何把「上一轮结果」带入下一轮 + +引擎行为(简化理解): + +- 每轮会构造 `loop_input = { **原输入, item变量, item_index, item_total }`。 +- 循环体内每执行一个节点,若输出为 **dict**,会 **merge** 进下一轮同轮内的 `loop_input`;非 dict 则写入 `result` 键。 + +因此: + +- **同轮内**链式节点:后一节点能读到前一节点合并进 `loop_input` 的字段。 +- **轮与轮之间**:下一轮会重新从**原始 `input_data`** 展开并覆盖 `item`;同轮内合并的字段**不会自动**跨轮保留,除非上游 Transform 把「需要跨轮的状态」写进**不变的全局字段**(例如仍来自 Start/Cache 的 `memory`),或在每轮依赖的数组项里自带「累计状态」。 + +若业务强依赖「第 N 轮必须读到第 N-1 轮 LLM 全文」,建议: + +- 在循环体末尾加 **Code/Transform**,把本轮产出写入**结构化字段**,并通过**下一轮 `item` 内容**(由上游数组生成)携带;或 +- 仍用 **A 档(单节点 + max_tool_iterations)** 在一轮推理里完成链式工具与总结。 + +--- + +## 5. 循环体拓扑与引擎限制(必读) + +引擎对循环体实现为 **简化版**: + +1. **只走 loop 的第一条出边**指向的**单链**,直到遇到 `loop_end` 或 `end` 类型节点停止。 +2. **不要**在循环体内再嵌套复杂分支;多出口 loop 未覆盖的场景可能不符合预期。 +3. **错误处理**:`error_handling` 为 `continue` 时失败轮次会往结果里塞 `null`;`stop` 则整段失败。 + +画布上需包含:`loop` → … → `loop_end`(或 `end`)的链,以满足「链尾」语义。 + +--- + +## 6. `node_outputs`、End 与「谁产出最终 reply」 + +主流程在跑完 **loop** 后,会把 **loop 节点本身** 的输出写入 `node_outputs[loop节点id]`,值为 **`output`: 每轮结果的数组**(每轮取循环体**最后一个节点**的 `output`)。 + +**重要**:循环体内的 LLM 节点在引擎里会随 loop **被标记为已执行**,但**不会**再走主线程里「每个节点写一次 `node_outputs`」的路径;因此依赖 `_extract_reply_from_llm_node_outputs` 等「扫描所有 LLM 节点输出」的逻辑,**可能拿不到**循环体内的那个 LLM。 + +**推荐做法**: + +- 把「给用户的一句话」放在 **Loop 之后的 Transform** 里,从 **loop 输出数组** 生成 `reply` / `right`;或 +- 让 **End** 的前驱只依赖 **已合并好的单字段**,而不是隐式依赖某个 `llm-xxx` 的 `node_outputs`。 + +--- + +## 7. 与「同一次 API 多次跑 LLM」的关系 + +- **Loop**:同一请求内,对数组长度 **N** 次调用循环体(若体内含 LLM,即 **N 次独立 `execute_node` LLM**)。 +- **`max_tool_iterations`**:同一 **LLM 节点一次调用** 内的 tool 轮数,**不是** N 次顶层 LLM。 + +二者可并存:例如 loop 两轮,每轮 LLM 再 `max_tool_iterations=14`,但总成本与延迟会明显上升,需在运营侧限制 `items` 长度与并发策略(若后续引擎支持)。 + +--- + +## 8. 最小示意(节点意图,非完整 JSON) + +- `code-prepare`:输出 `loop_rounds: [1,2,3]`(或真实子任务数组)。 +- `loop-zhini`:`items_path=loop_rounds`,`item_variable=round`。 +- 子链:`llm-subtask`(专用 id,提示词含本轮任务与 `{{round}}`)→ `loop_end`。 +- `transform-merge`:输入为 loop 输出数组 → 拼成最终 `reply`。 +- `cache-save` / `end`:只依赖 `transform-merge`。 + +--- + +## 9. 风险与降级 + +| 风险 | 应对 | +|------|------| +| 循环体只能单链、多分支不支持 | 业务拆成多段 Transform 或回到 **A 档** | +| 跨轮状态不自动传递 | 用数组项携带状态,或单节点长链工具 | +| End 收不到体内 LLM | **必须**经 loop 输出或后续 Transform 显式合并 | +| 成本/超时 | 限制 `len(items)`,必要时产品侧改需求为单节点多工具 | + +**默认推荐**:若仅为「多步工具 + 可持续任务汇报」,优先保持 **15 号现有 `llm-unified` + `max_tool_iterations` + 提示词**;仅在产品明确要求「按列表逐条推理」时启用 **loop 方案**。 + +--- + +## 10. 与脚本/文档的对应 + +- 工作流脚本:`backend/scripts/create_zhini_kefu_15.py`(当前为单 LLM 主线;若落地 loop,需另存副本或版本化,避免破坏 14/15 默认行为)。 +- 能力说明:`知你客服15号能力文档.md`(可与本节交叉引用「进阶:Loop 多轮」)。 + +--- + +*文档版本:与引擎 `workflow_engine.py` 中 loop / `_execute_loop_body` 行为对齐;若引擎升级多链循环或 `node_outputs` 写入规则变化,需同步修订本节。* diff --git a/知你客服15号能力文档.md b/知你客服15号能力文档.md new file mode 100644 index 0000000..c0707e6 --- /dev/null +++ b/知你客服15号能力文档.md @@ -0,0 +1,34 @@ +# 知你客服 15 号 · 能力说明 + +## 定位 + +在 **知你客服 14 号**(全量内置工具)基础上,强调 **可持续、多步完成任务**: + +- **单次执行内**:通过 LLM 节点配置 **`max_tool_iterations`**(默认 **28**),引擎在同一轮用户请求中允许多次「模型 ↔ 工具」迭代,适合长链路(读→写→校验、多接口查询等),避免「只调一次工具就收尾」。 +- **提示词**:要求模型主动规划多步工具链,并在末行 JSON 中可选用 `task_complete`、`progress_report`、`continuation_hint` 等字段反馈进度。 +- **跨轮**:若单次仍无法跑完,由模型在回复中引导用户下一轮发「继续」,并依赖现有 **会话记忆**(Cache / `user_memory_*`)衔接。 + +## 与 14 号的差异 + +| 项目 | 14 号 | 15 号 | +|------|--------|--------| +| 工具列表 | 10 个内置工具 | **相同** | +| `max_tool_iterations` | 未设(引擎默认 5) | **28**(可在设计器 `llm-unified` 的 data 中改) | +| 提示词 | 扩展工具说明 | 增加 **可持续任务执行** 与 JSON 进度字段 | + +## 引擎支持 + +工作流引擎从 LLM 节点读取 **`max_tool_iterations`** 或 **`max_tool_call_rounds`**(整数, clamp 1~64),传入 `llm_service.call_llm_with_tools` 的 `max_iterations`。 + +## 创建 / 更新脚本 + +```text +backend/scripts/create_zhini_kefu_15.py +``` + +默认从 **知你客服14号** 复制,目标 **知你客服15号**;若已存在则更新工作流与描述。 + +## 限制说明 + +- **「一直干到完」**在技术上受 **模型上下文、工具迭代上限、单次执行超时** 约束;超长任务需用户多轮「继续」或拆分需求。 +- 工具种类与 14 号一致;若需更多**类型**的能力,需在平台增加新内置工具或 HTTP 工具后再挂到 Agent。 diff --git a/知你客服16号能力文档.md b/知你客服16号能力文档.md new file mode 100644 index 0000000..1eb3560 --- /dev/null +++ b/知你客服16号能力文档.md @@ -0,0 +1,39 @@ +# 知你客服 16 号 · 能力说明(B:Loop + 循环体内 LLM) + +## 定位 + +在 **知你客服 15 号**(全量工具 + 单节点 `max_tool_iterations`)基础上,采用 **B 能力**:在同一次 API 调用内,用 **循环节点** 对 `loop_rounds` 数组**逐段**执行 **`zhini16-llm-subtask`**,再通过 **`zhini16-code-merge-rounds`** 合并各段 `reply`,供下游 Cache / End 使用。 + +| 项目 | 15 号 | 16 号 | +|------|--------|--------| +| 多轮工具 | 单 LLM 节点内 `max_tool_iterations` | **每段**子 LLM 仍有 `max_tool_iterations`(默认 12) | +| 多次顶层 LLM | 无(单次 `llm-unified`) | **有**(段数 = `len(loop_rounds)`,默认 3) | +| 主链 `llm-unified` | 使用 | **从主链摘除**(节点可保留在画布,无连线) | + +## 拓扑(脚本写入) + +1. `zhini16-code-build-rounds`:注入 `loop_rounds`(默认 `[1,2,3]`,可由环境变量改段数)。 +2. `zhini16-loop-main`:`items_path=loop_rounds`,`item_variable=round`。 +3. 循环体:`zhini16-llm-subtask` → `zhini16-loop-end`(**须为 loop 的第一条出边**,脚本已排序)。 +4. 循环后:`zhini16-code-merge-rounds`:解析各段末行 JSON,拼接 `reply`,合并 `user_profile` 写入 `user_profile_update`,并设置 `right` / `reply`。 +5. 原 `llm-unified` 的后继(如 Cache 写、End)接到 `merge` 之后。 + +## 引擎与限制 + +- 循环体为**单链**简化实现;勿在段内再叠复杂分支。 +- 段与段之间**不自动**传递上一轮合并后的临时字段,仅依赖上游传入的 `memory` / `query` 等(与 `知你客服15号_循环工作流设计.md` 一致)。 +- 总成本 ≈ **段数 ×(每段 tokens + 工具调用)**,请控制 `ZHINI16_LOOP_ROUNDS`(上限 8)。 + +## 创建 / 更新脚本 + +```text +backend/scripts/create_zhini_kefu_16.py +``` + +- 默认从 **知你客服15号** 复制为 **知你客服16号**;已存在则更新工作流与描述。 +- 环境变量:`ZHINI16_LOOP_ROUNDS`(默认 3)、`ZHINI16_SUBTASK_MAX_TOOL_ITERATIONS`(默认 12)。 + +## 与 15 号文档的关系 + +- **A 能力**(仅提高 `max_tool_iterations`):见 `知你客服15号能力文档.md`。 +- **B 能力**(Loop 拓扑与 `node_outputs` 注意点):见 `知你客服15号_循环工作流设计.md`。