feat: 工具系统迁移 + 重型插件骨架 + 前端交互增强
- 工具系统从 kilostar/plugin/tool_plugin/ 迁移到 data/toolset/(manifest.json 声明式) - 新增 plugin_runtime 模块:BaseOrganization / GlobalPluginManager / loader / tool_bridge - 新增 org_task + org_task_event 表及 DAO(alembic 0009) - 新增 /api/v1/plugin 路由(submit/status/stream/install/reload) - 新增 data/plugin/example_dept 示例重型插件 - regulatory_node 支持聊天历史上下文注入 - send_file 改为 artifact 存盘 + SSE 推送下载链接 - 前端 WorkflowFileCard 组件 + ToolSettings README 渲染 - utils 整理:合并 access/role_check、standalone_proxy→ray_compat、删除废弃模块 - 项目结构文档移至 docs/STRUCTURE.md 并详细展开 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -11,5 +11,8 @@ wheels/
|
||||
.idea
|
||||
# Local runtime data (MCP registry, etc.)
|
||||
data/
|
||||
!data/plugin/
|
||||
data/plugin/skill/
|
||||
!data/toolset/
|
||||
tmp/
|
||||
.env
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[](https://ai.pydantic.dev/)
|
||||
[](LICENSE)
|
||||
|
||||
[English](./README-EN.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**未来展望**](./changelogs/ROADMAP.md)
|
||||
[English](./README-EN.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**项目结构**](docs/STRUCTURE.md) | [**未来展望**](./changelogs/ROADMAP.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
### 🧩 插件体系
|
||||
- **工具插件**:标准 Tool 调用,支持 MCP 协议接入第三方服务
|
||||
- **Skill(兼容 Anthropic Agent Skills 标准)**:通过 [viceroy](https://github.com/zhaoxi826/viceroy) 安装解析,运行时按需加载
|
||||
- **重型插件(规划中)**:带独立 UI 的垂直应用包,把 KiloStar 改造成专用 Agent 平台
|
||||
- **重型插件(Organization)**:带独立工具集、多 Agent 团队与前端面板的垂直应用包,以"部门"身份接入系统内阁
|
||||
|
||||
### 🛡️ 安全设计
|
||||
- **JWT 鉴权**:所有 API 端点(含 SSE 事件流)均走 Bearer Token 认证
|
||||
@@ -129,58 +129,7 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
KiloStar/
|
||||
├── main.py # 应用入口(FastAPI + Ray 初始化)
|
||||
├── pyproject.toml # Python 依赖与项目元数据
|
||||
├── Dockerfile / docker-compose.yml # 容器化部署
|
||||
├── alembic/ # 数据库迁移脚本
|
||||
├── config/ # 环境配置模板
|
||||
├── kilostar/ # 后端核心包
|
||||
│ ├── api/ # FastAPI 路由层
|
||||
│ │ ├── system.py # /health 系统健康检查
|
||||
│ │ ├── workflow.py # /workflow CRUD + SSE + resume
|
||||
│ │ ├── chat.py # /chat 会话管理
|
||||
│ │ ├── agent.py # /agent Worker 管理
|
||||
│ │ └── resource.py # /resource Skill/Toolset 管理
|
||||
│ ├── core/ # 核心业务逻辑
|
||||
│ │ ├── individual/ # 各类 Agent 节点实现
|
||||
│ │ │ ├── consciousness_node/ # 意识节点(任务规划)
|
||||
│ │ │ ├── regulatory_node/ # 监管节点(质量把关)
|
||||
│ │ │ ├── control_node/ # 控制节点(路由调度)
|
||||
│ │ │ └── growth_node/ # 生长节点(能力扩展)
|
||||
│ │ ├── work/ # 工作执行层
|
||||
│ │ │ ├── workflow/ # 工作流引擎(pydantic-graph)
|
||||
│ │ │ ├── chat/ # 对话处理
|
||||
│ │ │ └── task/ # 单任务执行
|
||||
│ │ ├── global_state_machine/ # 全局状态机(Provider/Config)
|
||||
│ │ ├── global_workflow_manager/ # 工作流消息队列 Actor
|
||||
│ │ └── postgres_database/ # PostgreSQL DAO 层
|
||||
│ ├── adapter/ # 模型适配器(OpenAI/vLLM/...)
|
||||
│ ├── plugin/ # 工具插件
|
||||
│ │ └── tool_plugin/ # Tavily / FileReader / Approval
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── access.py # JWT 认证
|
||||
│ │ ├── ray_hook.py # Ray Actor 句柄获取
|
||||
│ │ └── check_user/ # 角色鉴权
|
||||
│ ├── worker_cluster/ # Worker 集群管理
|
||||
│ └── worker_individual/ # Worker 个体生命周期
|
||||
├── frontend/ # React 前端(Vite + Tailwind)
|
||||
│ └── src/
|
||||
│ ├── api/ # Axios client + SSE 封装
|
||||
│ ├── components/ # UI 组件
|
||||
│ │ ├── Chat/ # 工作流面板 + 实时图
|
||||
│ │ ├── Agent/ # Worker/Provider 管理
|
||||
│ │ ├── Plugin/ # Skill/Tool 配置
|
||||
│ │ └── Settings/ # 系统设置
|
||||
│ ├── i18n/ # 国际化(中/英)
|
||||
│ ├── store/ # Zustand 状态管理
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── tests/ # 测试套件(249+ 用例)
|
||||
│ ├── unit/ # 单元测试
|
||||
│ └── integration/ # 集成 smoke 测试
|
||||
└── docs/ # 设计文档
|
||||
```
|
||||
详见 [docs/STRUCTURE.md](docs/STRUCTURE.md)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add org_task and org_task_event tables for heavy plugin system
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-06-16
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0009"
|
||||
down_revision = "0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"org_task",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.String(64), unique=True, index=True),
|
||||
sa.Column("org_name", sa.String(128), index=True),
|
||||
sa.Column("status", sa.String(20), index=True, server_default="pending"),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("result", sa.Text(), nullable=True),
|
||||
sa.Column("context", JSONB, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"org_task_event",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.String(64), index=True),
|
||||
sa.Column("event_type", sa.String(30), index=True),
|
||||
sa.Column("payload", JSONB, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
# 项目结构
|
||||
|
||||
> 最后更新:2026-06-17
|
||||
|
||||
```
|
||||
KiloStar/
|
||||
├── main.py # 应用入口(standalone / distributed 双模式)
|
||||
├── pyproject.toml # Python 依赖与项目元数据(uv 管理)
|
||||
├── Dockerfile / docker-compose.yml # 容器化部署
|
||||
├── alembic/ # 数据库迁移脚本(顺序编号 0001~0009)
|
||||
├── config/ # 环境配置模板
|
||||
│ ├── .env.example # 环境变量模板
|
||||
│ ├── config.yml # 应用配置(provider/node 默认值)
|
||||
│ ├── workflow.yaml # 工作流重试策略
|
||||
│ └── sandbox.yaml # 沙箱策略(路径白名单/命令过滤)
|
||||
│
|
||||
├── kilostar/ # ===== 后端核心包 =====
|
||||
│ │
|
||||
│ ├── api/ # FastAPI 路由层(每个文件一个 APIRouter)
|
||||
│ │ ├── __init__.py # app 实例、中间件、异常处理、路由挂载
|
||||
│ │ ├── system.py # GET /health + /api/v1/system 系统信息
|
||||
│ │ ├── workflow.py # /api/v1/workflow CRUD / SSE / resume
|
||||
│ │ ├── chat.py # /api/v1/chat 对话(含历史上下文注入)
|
||||
│ │ ├── agent.py # /api/v1/agent Worker CRUD / 模板
|
||||
│ │ ├── resource.py # /api/v1/resource Toolset / Skill / Artifact
|
||||
│ │ ├── plugin.py # /api/v1/plugin 重型插件 submit/status/stream
|
||||
│ │ ├── provider.py # /api/v1/provider 模型供应商 CRUD
|
||||
│ │ ├── auth.py # /api/v1/auth 登录/注册/改密
|
||||
│ │ └── platform/ # 平台接入
|
||||
│ │ ├── frontend.py # SPA 静态资源 fallback
|
||||
│ │ └── onebot.py # OneBot v11 协议适配
|
||||
│ │
|
||||
│ ├── core/ # 核心业务逻辑
|
||||
│ │ ├── individual/ # 系统 Agent 节点
|
||||
│ │ │ ├── consciousness_node/ # 意识节点:接收用户命令 → 设计工作流 DAG
|
||||
│ │ │ │ ├── consciousness_node.py
|
||||
│ │ │ │ └── template.py # structured output 模板
|
||||
│ │ │ ├── regulatory_node/ # 监管节点:直面用户对话、质量把关
|
||||
│ │ │ │ ├── regulatory_node.py
|
||||
│ │ │ │ └── template.py
|
||||
│ │ │ ├── control_node/ # 控制节点:工作流节点内路由调度
|
||||
│ │ │ │ ├── control_node.py
|
||||
│ │ │ │ └── template.py
|
||||
│ │ │ └── growth_node/ # 生长节点:能力自扩展(占位)
|
||||
│ │ │
|
||||
│ │ ├── work/ # 工作执行层
|
||||
│ │ │ ├── workflow/ # 工作流引擎
|
||||
│ │ │ │ ├── workflow_engine.py # 轮询 + 调度主循环
|
||||
│ │ │ │ ├── workflow.py # pydantic-graph 节点定义
|
||||
│ │ │ │ ├── model.py # WorkflowState / StepResult 等
|
||||
│ │ │ │ └── graph_persistence.py # 执行状态持久化到 PG
|
||||
│ │ │ ├── chat/ # 对话处理(占位)
|
||||
│ │ │ └── task/ # 短任务执行(占位)
|
||||
│ │ │
|
||||
│ │ ├── global_state_machine/ # 全局状态机 Actor(系统唯一真相源)
|
||||
│ │ │ ├── global_state_machine.py # GSM 主体:初始化、注册表读写、toolset 补种
|
||||
│ │ │ ├── gsm_snapshot.py # 不可变快照(放入 Ray Object Store 供快读)
|
||||
│ │ │ ├── individual_manager.py # Individual 注册/查询/删除
|
||||
│ │ │ ├── provider_manager.py # Provider 注册/CRUD/test_connection
|
||||
│ │ │ ├── tool_manager.py # Toolset 加载(读 manifest.json)+ 工具分发
|
||||
│ │ │ ├── skill_manager.py # Skill 元数据注册/查询
|
||||
│ │ │ └── model_provider/ # Provider 适配(per-vendor 子类)
|
||||
│ │ │ ├── base_provider.py # 抽象基类
|
||||
│ │ │ ├── openai_provider.py # OpenAI / 兼容接口
|
||||
│ │ │ ├── claude_provider.py # Anthropic Claude
|
||||
│ │ │ ├── gemini_provider.py # Google Gemini
|
||||
│ │ │ └── deepseek_provider.py # DeepSeek
|
||||
│ │ │
|
||||
│ │ ├── global_workflow_manager/ # 工作流调度 Actor
|
||||
│ │ │ └── global_workflow_manager.py # 消息队列、pending workflow 轮询
|
||||
│ │ │
|
||||
│ │ └── postgres_database/ # PostgreSQL DAO 层(Actor 门面模式)
|
||||
│ │ ├── postgres.py # 统一门面:组合所有子 DAO,ready_event 守卫
|
||||
│ │ ├── database_exception.py# @database_exception 装饰器(统一异常包装)
|
||||
│ │ ├── model/ # SQLAlchemy ORM 模型
|
||||
│ │ │ ├── base.py # DeclarativeBase
|
||||
│ │ │ ├── user.py # User + UserAuthority
|
||||
│ │ │ ├── provider.py # ProviderModel
|
||||
│ │ │ ├── individual.py # Base/Specialist/Ordinary/Special Individual
|
||||
│ │ │ ├── workflow.py # Workflow + Context + GraphState
|
||||
│ │ │ ├── chat_history.py # ChatHistoryRegister + Message
|
||||
│ │ │ ├── system_node.py # SystemNodeConfigModel
|
||||
│ │ │ ├── mcp_server.py # MCPServerModel
|
||||
│ │ │ ├── tool_config.py # ToolConfigModel
|
||||
│ │ │ ├── custom_toolset.py# CustomToolsetModel
|
||||
│ │ │ ├── persona_template.py# PersonaTemplate
|
||||
│ │ │ ├── system_event_log.py# SystemEventLog
|
||||
│ │ │ ├── org_task.py # OrgTask(重型插件任务)
|
||||
│ │ │ └── org_task_event.py# OrgTaskEvent(任务事件流)
|
||||
│ │ └── module/ # 各表 DAO 实现(async session + CRUD)
|
||||
│ │ ├── user.py / provider.py / individual.py / ...
|
||||
│ │ └── org_task.py # 重型插件任务 + 事件 DAO
|
||||
│ │
|
||||
│ ├── plugin_runtime/ # 重型插件(Organization)运行时
|
||||
│ │ ├── base_organization.py # 基类:asyncio.Queue 消费、dispatch/submit 双通道
|
||||
│ │ │ # react 循环、consult 工具、PG 持久化
|
||||
│ │ ├── plugin_manager.py # GlobalPluginManager Actor:bootstrap/install/reload
|
||||
│ │ ├── loader.py # discover_plugins + load_plugin + uv 依赖安装
|
||||
│ │ ├── tool_bridge.py # make_dispatch_tool() → 生成 dispatch_to_<org> 函数
|
||||
│ │ ├── manifest.py # OrgManifest pydantic 模型
|
||||
│ │ ├── agents_config.py # AgentsConfig / AgentDef / orchestration
|
||||
│ │ └── event.py # OrgEvent / OrgEventType / TaskState
|
||||
│ │
|
||||
│ ├── adapter/ # 模型适配器
|
||||
│ │ └── model_adapter/
|
||||
│ │ ├── agent_factory.py # AgentFactory:根据 provider+model 构建 pydantic-ai Agent
|
||||
│ │ └── deepseek_reasoner.py # DeepSeek R1 reasoning 特殊适配
|
||||
│ │
|
||||
│ ├── utils/ # ===== 工具函数层 =====
|
||||
│ │ ├── settings.py # AppSettings(pydantic-settings)+ 路径工具
|
||||
│ │ │ # get_settings / get_toolset_dir / get_plugin_dir / get_artifact_dir
|
||||
│ │ ├── config_loader.py # 多 YAML 统一加载 → AppConfig(workflow/sandbox/应用配置)
|
||||
│ │ ├── ray_compat.py # standalone/distributed 兼容层
|
||||
│ │ │ # @actor_class 装饰器、StandaloneProxy、_STANDALONE 标志
|
||||
│ │ ├── ray_hook.py # ray_actor_hook():按名字获取 Actor 句柄(两种模式统一)
|
||||
│ │ ├── access.py # JWT 认证 + RBAC 鉴权(Accessor / TokenData / RoleChecker)
|
||||
│ │ ├── crypto.py # Fernet 对称加密(API key 等敏感字段落盘加密)
|
||||
│ │ ├── error.py # 统一异常体系:KiloStarError / BusinessError / InfraError
|
||||
│ │ ├── logger.py # loguru + rich 日志(get_logger 按模块名取 logger)
|
||||
│ │ ├── request_context.py # contextvars 双层 ID:request_id + trace_id 传播
|
||||
│ │ ├── get_tool.py # 按工具名动态加载函数(扫描 manifest → importlib)
|
||||
│ │ ├── mcp_helper.py # MCP Server 实例创建(stdio/sse/http 三种传输)
|
||||
│ │ ├── sandbox.py # 工具沙箱:路径校验、命令黑名单、Python AST 检查
|
||||
│ │ ├── agent_model.py # Agent 通用 pydantic 响应模型(ResponseModel 等)
|
||||
│ │ ├── prompts.py # 系统节点 system prompt 模板(按角色×locale)
|
||||
│ │ ├── rate_limit.py # 滑动窗口内存限流器(按 IP,单实例用)
|
||||
│ │ ├── retry.py # @retry_on_retryable_error 装饰器(指数退避)
|
||||
│ │ ├── banner.py # 启动 banner ASCII art
|
||||
│ │ └── i18n.py # 国际化翻译(t() 函数,accept-language 解析)
|
||||
│ │
|
||||
│ ├── worker_cluster/ # Worker 集群管理
|
||||
│ │ └── worker_cluster.py # WorkerCluster Actor:按资源标签管理 worker 池
|
||||
│ │ # CPU / Core / GPU 三类,分布式下各一个 Actor
|
||||
│ │
|
||||
│ └── worker_individual/ # Worker 个体
|
||||
│ ├── base_individual.py # 抽象基类(生命周期 + 工具绑定)
|
||||
│ ├── ordinary_individual.py # 通用 Worker(接受任意 system prompt)
|
||||
│ ├── skill_individual.py # Skill Worker(加载指定 skill 执行)
|
||||
│ └── special_individual.py # 特殊 Worker(embedding/TTS/图像,占位)
|
||||
│
|
||||
├── data/ # ===== 数据/插件目录(运行时读取)=====
|
||||
│ ├── toolset/ # 工具集(每个子目录 = 一个 toolset)
|
||||
│ │ ├── base_toolset/ # 系统基础工具
|
||||
│ │ │ ├── manifest.json # 声明 7 个工具的元数据
|
||||
│ │ │ ├── shell_executor.py # Shell 命令执行
|
||||
│ │ │ ├── file_reader.py # 文件读取
|
||||
│ │ │ ├── edit_file.py # 文件编辑(diff patch)
|
||||
│ │ │ ├── write_file.py # 文件写入
|
||||
│ │ │ ├── search_file.py # 文件搜索(glob + grep)
|
||||
│ │ │ ├── python_executor.py # Python 代码执行(沙箱内)
|
||||
│ │ │ └── tavily_search.py # Tavily 网络搜索
|
||||
│ │ └── interactive_toolset/ # 交互工具(需要人/系统介入)
|
||||
│ │ ├── manifest.json
|
||||
│ │ ├── approval.py # 人工审批节点
|
||||
│ │ └── send_file.py # 文件下发(存 artifact + 推 SSE)
|
||||
│ │
|
||||
│ └── plugin/ # 重型插件(每个子目录 = 一个 Organization)
|
||||
│ └── example_dept/ # 示例插件(开发模板)
|
||||
│ ├── manifest.json # 插件元数据(name/entry/concurrency/...)
|
||||
│ ├── agents.json # 内部 agent 定义(analyst + executor)
|
||||
│ ├── README.md
|
||||
│ ├── core/ # 业务逻辑入口
|
||||
│ │ └── organization.py # ExampleOrganization(BaseOrganization)
|
||||
│ ├── toolset/ # 插件本地工具(可选)
|
||||
│ ├── skills/ # 插件本地技能(可选)
|
||||
│ └── dashboard/ # 前端面板(占位,Tauri 化后接通)
|
||||
│
|
||||
├── frontend/ # ===== React 前端(Vite + TypeScript + Tailwind)=====
|
||||
│ └── src/
|
||||
│ ├── api/ # Axios client + SSE 封装 + 类型化请求
|
||||
│ ├── assets/ # 静态资源(图标/字体)
|
||||
│ ├── hooks/ # 自定义 React hooks
|
||||
│ ├── components/ # UI 组件
|
||||
│ │ ├── Chat/ # 工作流面板 + 实时日志 + 文件卡片
|
||||
│ │ ├── Agent/ # Worker / Provider 管理
|
||||
│ │ ├── Plugin/ # Skill / Toolset / MCP 配置
|
||||
│ │ ├── Auth/ # 登录 / 注册
|
||||
│ │ ├── Layout/ # 布局骨架 + 导航
|
||||
│ │ └── Settings/ # 系统设置
|
||||
│ ├── i18n/ # 国际化
|
||||
│ │ └── locales/ # zh.json / en.json
|
||||
│ ├── store/ # Zustand 状态管理
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
│
|
||||
├── tests/ # ===== 测试套件(331 用例)=====
|
||||
│ ├── unit/ # 单元测试(纯逻辑,mock 外部依赖)
|
||||
│ └── integration/ # 集成测试(启动真实服务)
|
||||
│
|
||||
└── subprojects/ # ===== 子项目 =====
|
||||
└── stardomain/ # Skill 脚本沙箱执行(local + Docker 双模式)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计模式
|
||||
|
||||
| 模式 | 说明 |
|
||||
|:--|:--|
|
||||
| `@actor_class` | 装饰器统一 standalone(普通对象)和 distributed(Ray Actor)两种运行形态 |
|
||||
| `ray_actor_hook(name)` | 按名字获取 actor 句柄,两种模式下接口一致 |
|
||||
| `StandaloneProxy` | 将普通方法调用包装为 `.remote()` 语法兼容 |
|
||||
| GSM Snapshot | 不可变快照放入 Object Store,各节点快速读取无需 RPC |
|
||||
| DAO 门面 | `PostgresDatabase` 组合所有子 DAO,`ready_event` 确保初始化后才放行 |
|
||||
| manifest.json 声明式 | 工具集/插件元数据与代码分离,支持热发现和前端展示 |
|
||||
| dispatch / submit 双通道 | 重型插件对内阁阻塞(dispatch),对用户射后不管(submit) |
|
||||
@@ -7,6 +7,35 @@ import type { SSEConnection } from '../../api/sse';
|
||||
import type { WorkflowDetail } from '../../types';
|
||||
import { ErrorBoundary } from '../ErrorBoundary';
|
||||
import { WorkflowDiagram } from './WorkflowDiagram';
|
||||
import { WorkflowFileCard, type WorkflowFilePayload } from './WorkflowFileCard';
|
||||
|
||||
type LogEntry =
|
||||
| { kind: 'text'; content: string }
|
||||
| { kind: 'file'; payload: WorkflowFilePayload };
|
||||
|
||||
const FILE_PREFIX = '__FILE__';
|
||||
|
||||
function parseLogEvent(data: string): LogEntry {
|
||||
if (data.startsWith(FILE_PREFIX)) {
|
||||
try {
|
||||
const parsed = JSON.parse(data.slice(FILE_PREFIX.length));
|
||||
if (parsed && typeof parsed.filename === 'string' && typeof parsed.url === 'string') {
|
||||
return {
|
||||
kind: 'file',
|
||||
payload: {
|
||||
filename: parsed.filename,
|
||||
url: parsed.url,
|
||||
artifact_id: parsed.artifact_id,
|
||||
size: typeof parsed.size === 'number' ? parsed.size : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* fall through to text */
|
||||
}
|
||||
}
|
||||
return { kind: 'text', content: data };
|
||||
}
|
||||
|
||||
interface RightPanelProps {
|
||||
selectedWorkflow: string | null;
|
||||
@@ -16,7 +45,7 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [detail, setDetail] = useState<WorkflowDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [sseConnected, setSseConnected] = useState(false);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [resuming, setResuming] = useState(false);
|
||||
@@ -54,13 +83,13 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
token,
|
||||
{
|
||||
onOpen: () => setSseConnected(true),
|
||||
onMessage: (data) => setLogs((prev) => [...prev, data]),
|
||||
onMessage: (data) => setLogs((prev) => [...prev, parseLogEvent(data)]),
|
||||
onError: () => setSseConnected(false),
|
||||
onReconnect: (delayMs) => {
|
||||
setSseConnected(false);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`[System]: ${t('workflow.sseReconnecting', { seconds: Math.round(delayMs / 1000) })}`,
|
||||
{ kind: 'text', content: `[System]: ${t('workflow.sseReconnecting', { seconds: Math.round(delayMs / 1000) })}` },
|
||||
]);
|
||||
},
|
||||
},
|
||||
@@ -82,11 +111,11 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
if (!replyText.trim() || !selectedWorkflow) return;
|
||||
const message = replyText.trim();
|
||||
setReplyText('');
|
||||
setLogs((prev) => [...prev, `[You]: ${message}`]);
|
||||
setLogs((prev) => [...prev, { kind: 'text', content: `[You]: ${message}` }]);
|
||||
try {
|
||||
await apiClient.post(`/api/v1/workflow/reply/${selectedWorkflow}`, { message });
|
||||
} catch {
|
||||
setLogs((prev) => [...prev, `[System Error]: Failed to send reply.`]);
|
||||
setLogs((prev) => [...prev, { kind: 'text', content: `[System Error]: Failed to send reply.` }]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,11 +124,11 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
setResuming(true);
|
||||
try {
|
||||
await apiClient.post(`/api/v1/workflow/${selectedWorkflow}/resume`);
|
||||
setLogs((prev) => [...prev, `[System]: ${t('workflow.resumeTriggered')}`]);
|
||||
setLogs((prev) => [...prev, { kind: 'text', content: `[System]: ${t('workflow.resumeTriggered')}` }]);
|
||||
fetchDetail(selectedWorkflow);
|
||||
} catch (err: any) {
|
||||
const detailMsg = err?.response?.data?.detail || t('workflow.resumeFailed');
|
||||
setLogs((prev) => [...prev, `[System Error]: ${detailMsg}`]);
|
||||
setLogs((prev) => [...prev, { kind: 'text', content: `[System Error]: ${detailMsg}` }]);
|
||||
} finally {
|
||||
setResuming(false);
|
||||
}
|
||||
@@ -220,11 +249,16 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
{t('workflow.waitingEvents')}
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} className={`p-2.5 rounded-lg text-xs ${log.startsWith('[You]') ? 'bg-accent-light/50 text-accent-text ml-8' : 'bg-bg-secondary text-text-secondary mr-8'}`}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
logs.map((log, index) => {
|
||||
if (log.kind === 'file') {
|
||||
return <WorkflowFileCard key={index} payload={log.payload} />;
|
||||
}
|
||||
return (
|
||||
<div key={index} className={`p-2.5 rounded-lg text-xs ${log.content.startsWith('[You]') ? 'bg-accent-light/50 text-accent-text ml-8' : 'bg-bg-secondary text-text-secondary mr-8'}`}>
|
||||
{log.content}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { FileText, Download, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
export interface WorkflowFilePayload {
|
||||
filename: string;
|
||||
url: string;
|
||||
artifact_id?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes && bytes !== 0) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function WorkflowFileCard({ payload }: { payload: WorkflowFilePayload }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await apiClient.get(payload.url, { responseType: 'blob' });
|
||||
const blob = new Blob([resp.data]);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = payload.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.status === 403 ? t('workflow.fileForbidden') : t('workflow.fileDownloadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border-primary rounded-xl px-3 py-2.5 mr-8 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-accent-light flex items-center justify-center shrink-0">
|
||||
<FileText size={18} className="text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary truncate">
|
||||
{payload.filename}
|
||||
</div>
|
||||
{payload.size !== undefined && (
|
||||
<div className="text-[11px] text-text-muted mt-0.5">
|
||||
{formatSize(payload.size)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={loading}
|
||||
className="p-1.5 rounded-lg hover:bg-accent-light text-text-muted hover:text-accent transition-colors disabled:opacity-40"
|
||||
title={t('workflow.fileDownload')}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-2 text-[11px] text-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Package, Wrench, Loader2, Box, Shield, X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
interface Toolset {
|
||||
@@ -145,13 +147,36 @@ function ToolsetModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [readme, setReadme] = useState<string | null>(null);
|
||||
const [readmeState, setReadmeState] = useState<'idle' | 'loading' | 'missing' | 'loaded'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolset.is_system) return;
|
||||
let cancelled = false;
|
||||
setReadmeState('loading');
|
||||
apiClient
|
||||
.get(`/api/v1/resource/toolset-package/${encodeURIComponent(toolset.category)}/readme`)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setReadme(res.data.content || '');
|
||||
setReadmeState('loaded');
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setReadmeState('missing');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [toolset.category, toolset.is_system]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-bg-card border border-border-primary rounded-2xl shadow-lg w-full max-w-md mx-4"
|
||||
className="bg-bg-card border border-border-primary rounded-2xl shadow-lg w-full max-w-2xl mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-primary">
|
||||
@@ -172,19 +197,51 @@ function ToolsetModal({
|
||||
<X size={16} className="text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-2 max-h-80 overflow-y-auto">
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
{t('plugin.toolsetTools')}
|
||||
</p>
|
||||
{toolset.tools.map((tool) => (
|
||||
<div
|
||||
key={tool}
|
||||
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
|
||||
>
|
||||
<Package size={14} className="text-accent shrink-0" />
|
||||
<span className="text-sm text-text-primary">{tool}</span>
|
||||
<div className="px-5 py-4 max-h-[70vh] overflow-y-auto">
|
||||
{toolset.is_system ? (
|
||||
readmeState === 'loading' ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||||
<Loader2 size={18} className="animate-spin mr-2" />
|
||||
<span className="text-sm">{t('plugin.toolsetReadmeLoading')}</span>
|
||||
</div>
|
||||
) : readmeState === 'loaded' && readme ? (
|
||||
<div className="prose-chat text-[13.5px] text-text-primary leading-[1.75]">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{readme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
{t('plugin.toolsetReadmeMissing')}
|
||||
</p>
|
||||
{toolset.tools.map((tool) => (
|
||||
<div
|
||||
key={tool}
|
||||
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
|
||||
>
|
||||
<Package size={14} className="text-accent shrink-0" />
|
||||
<span className="text-sm text-text-primary">{tool}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
{t('plugin.toolsetTools')}
|
||||
</p>
|
||||
{toolset.tools.map((tool) => (
|
||||
<div
|
||||
key={tool}
|
||||
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
|
||||
>
|
||||
<Package size={14} className="text-accent shrink-0" />
|
||||
<span className="text-sm text-text-primary">{tool}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
"resumeTriggered": "Resume request sent, the workflow is recovering...",
|
||||
"resumeFailed": "Failed to resume workflow",
|
||||
"sseReconnecting": "Connection lost, retrying in {{seconds}}s...",
|
||||
"fileDownload": "Download",
|
||||
"fileDownloadFailed": "Download failed",
|
||||
"fileForbidden": "Not allowed to download this file",
|
||||
"workflowDetails": "Workflow Details",
|
||||
"loading": "Loading Workflows...",
|
||||
"titleRequired": "Please enter a workflow title",
|
||||
@@ -262,6 +265,8 @@
|
||||
"toolsetEmpty": "No toolsets available",
|
||||
"toolsetSystem": "System",
|
||||
"toolsetCount": "{{count}} tools",
|
||||
"toolsetReadmeMissing": "No README provided for this package",
|
||||
"toolsetReadmeLoading": "Loading description…",
|
||||
"skillManagement": "Skill Management",
|
||||
"skillDesc": "Manage agent skills and functions",
|
||||
"installSkill": "Install Skill",
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
"resumeTriggered": "恢复请求已发送,工作流正在恢复中...",
|
||||
"resumeFailed": "恢复工作流失败",
|
||||
"sseReconnecting": "连接断开,{{seconds}}秒后重试...",
|
||||
"fileDownload": "下载附件",
|
||||
"fileDownloadFailed": "下载失败",
|
||||
"fileForbidden": "无权下载该文件",
|
||||
"workflowDetails": "工作流详情",
|
||||
"loading": "正在加载工作流...",
|
||||
"titleRequired": "请输入工作流标题",
|
||||
@@ -262,6 +265,8 @@
|
||||
"toolsetEmpty": "暂无工具集",
|
||||
"toolsetSystem": "系统",
|
||||
"toolsetCount": "{{count}} 个工具",
|
||||
"toolsetReadmeMissing": "该工具包没有提供 README",
|
||||
"toolsetReadmeLoading": "正在加载说明…",
|
||||
"skillManagement": "技能管理",
|
||||
"skillDesc": "管理代理技能和函数",
|
||||
"installSkill": "安装技能",
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from kilostar.utils.standalone_proxy import _STANDALONE
|
||||
from kilostar.utils.ray_compat import _STANDALONE
|
||||
from kilostar.utils.settings import get_settings
|
||||
|
||||
if not _STANDALONE:
|
||||
@@ -35,6 +35,7 @@ from .provider import provider_router
|
||||
from .resource import resource_router
|
||||
from .workflow import workflow_router
|
||||
from .chat import chat_router
|
||||
from .plugin import plugin_router
|
||||
from kilostar.utils.error import (
|
||||
KiloStarError,
|
||||
BusinessError,
|
||||
@@ -103,6 +104,7 @@ app.include_router(resource_router) # 资源路径
|
||||
app.include_router(agent_router) # agent路径
|
||||
app.include_router(workflow_router) # workflow路径
|
||||
app.include_router(chat_router) # chat路径
|
||||
app.include_router(plugin_router) # plugin路径
|
||||
|
||||
|
||||
@app.exception_handler(BusinessError)
|
||||
|
||||
@@ -17,11 +17,10 @@ from typing import Union
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, field_validator
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.access import Accessor, TokenData, RoleChecker
|
||||
from kilostar.core.postgres_database.model import AgentType
|
||||
from fastapi import HTTPException
|
||||
from typing import Optional, List, Dict
|
||||
from kilostar.utils.check_user.role_check import RoleChecker
|
||||
from kilostar.core.postgres_database.model import UserAuthority
|
||||
from kilostar.utils.mcp_helper import get_all_tools_and_toolsets_for_scope
|
||||
from kilostar.utils.i18n import t
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import Depends
|
||||
from pydantic import BaseModel
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.access import Accessor, TokenData, RoleChecker
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from kilostar.utils.check_user.role_check import RoleChecker
|
||||
from kilostar.core.postgres_database.model import UserAuthority
|
||||
from kilostar.utils.error import UserNotExistError
|
||||
from kilostar.utils.rate_limit import register_limiter, login_limiter
|
||||
|
||||
+46
-6
@@ -26,6 +26,40 @@ from kilostar.core.individual.regulatory_node.template import (
|
||||
|
||||
chat_router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
|
||||
|
||||
# 单次注入历史的最大轮数(user+assistant 算一轮),防止 token 爆炸。
|
||||
_HISTORY_MAX_TURNS = 20
|
||||
|
||||
|
||||
def _build_message_history(rows) -> list:
|
||||
"""把 DB 中的 ChatHistoryMessage 列表转成 pydantic-ai message_history 格式。
|
||||
|
||||
历史按时间升序,截取末尾最多 _HISTORY_MAX_TURNS*2 条;user 消息映射为
|
||||
``ModelRequest(parts=[UserPromptPart])``,assistant(``regulatory_node``)映射为
|
||||
``ModelResponse(parts=[TextPart])``。其它 owner 跳过。
|
||||
"""
|
||||
from pydantic_ai.messages import (
|
||||
ModelRequest, ModelResponse, UserPromptPart, TextPart,
|
||||
)
|
||||
|
||||
trimmed = rows[-(_HISTORY_MAX_TURNS * 2):]
|
||||
history: list = []
|
||||
for row in trimmed:
|
||||
owner = row.message_owner
|
||||
text = row.message
|
||||
if not text:
|
||||
continue
|
||||
if owner == "user":
|
||||
history.append(ModelRequest(parts=[UserPromptPart(content=text)]))
|
||||
elif owner == "regulatory_node":
|
||||
history.append(ModelResponse(parts=[TextPart(content=text)]))
|
||||
return history
|
||||
|
||||
|
||||
async def _load_message_history(chat_id: str) -> list:
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
rows = await postgres_database.list_chat_messages.remote(chat_id=chat_id)
|
||||
return _build_message_history(rows or [])
|
||||
|
||||
|
||||
def _extract_reply(resp: MessageResponse | None) -> str | None:
|
||||
"""从 RegulatoryNode.working 的输出里取出对用户的回复文本。
|
||||
@@ -39,7 +73,7 @@ def _extract_reply(resp: MessageResponse | None) -> str | None:
|
||||
|
||||
|
||||
async def _ask_regulatory(
|
||||
*, user_id: str, chat_id: str, message: str
|
||||
*, user_id: str, chat_id: str, message: str, message_history: list | None = None
|
||||
) -> str | None:
|
||||
"""统一封装 chat 入口对 RegulatoryNode 的调用。"""
|
||||
regulatory_node = ray_actor_hook("regulatory_node").regulatory_node
|
||||
@@ -49,7 +83,9 @@ async def _ask_regulatory(
|
||||
platform_id=chat_id,
|
||||
message=message,
|
||||
)
|
||||
resp: MessageResponse | None = await regulatory_node.working.remote(payload)
|
||||
resp: MessageResponse | None = await regulatory_node.working.remote(
|
||||
payload, message_history
|
||||
)
|
||||
return _extract_reply(resp)
|
||||
|
||||
|
||||
@@ -120,7 +156,8 @@ async def send_chat_message(
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
# 存用户消息
|
||||
# 先取历史(不含当前输入),再写入用户消息,避免历史里出现重复
|
||||
message_history = await _load_message_history(chat_id)
|
||||
await postgres_database.add_chat_message.remote(
|
||||
chat_id=chat_id, message=request.message, message_owner="user"
|
||||
)
|
||||
@@ -130,6 +167,7 @@ async def send_chat_message(
|
||||
user_id=token_data.user_id,
|
||||
chat_id=chat_id,
|
||||
message=request.message,
|
||||
message_history=message_history,
|
||||
)
|
||||
|
||||
# 存回复
|
||||
@@ -164,10 +202,12 @@ async def stream_chat_message(
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
"""SSE 流式聊天端点:standalone 模式下逐 token 流式输出;distributed 模式 fallback 到整段回复。"""
|
||||
from kilostar.utils.standalone_proxy import _STANDALONE
|
||||
from kilostar.utils.ray_compat import _STANDALONE
|
||||
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
|
||||
message_history = await _load_message_history(chat_id)
|
||||
|
||||
await postgres_database.add_chat_message.remote(
|
||||
chat_id=chat_id, message=request_body.message, message_owner="user"
|
||||
)
|
||||
@@ -183,7 +223,7 @@ async def stream_chat_message(
|
||||
|
||||
if not _STANDALONE:
|
||||
async def fallback_generator():
|
||||
resp = await regulatory_node.working.remote(payload)
|
||||
resp = await regulatory_node.working.remote(payload, message_history)
|
||||
full_response = resp.reply_message if resp else ""
|
||||
if full_response:
|
||||
await postgres_database.add_chat_message.remote(
|
||||
@@ -195,7 +235,7 @@ async def stream_chat_message(
|
||||
return StreamingResponse(fallback_generator(), media_type="text/event-stream")
|
||||
|
||||
token_queue = asyncio.Queue()
|
||||
stream_task = regulatory_node.stream_working.remote(payload, token_queue)
|
||||
stream_task = regulatory_node.stream_working.remote(payload, token_queue, message_history)
|
||||
|
||||
async def event_generator():
|
||||
full_response = ""
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
plugin_router = APIRouter(prefix="/api/v1/plugin", tags=["plugin"])
|
||||
|
||||
|
||||
class SubmitRequest(BaseModel):
|
||||
org_name: str
|
||||
task_description: str
|
||||
context: Optional[dict] = None
|
||||
|
||||
|
||||
@plugin_router.post("/submit")
|
||||
async def submit_task(
|
||||
req: SubmitRequest,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
|
||||
plugins = await pm.list_plugins.remote()
|
||||
if req.org_name not in plugins:
|
||||
raise HTTPException(404, f"Plugin '{req.org_name}' not found")
|
||||
|
||||
org = ray_actor_hook(f"org_{req.org_name}").get(f"org_{req.org_name}")
|
||||
ctx = req.context or {}
|
||||
ctx["user"] = token_data.username
|
||||
task_id = await org.submit.remote(req.task_description, ctx)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@plugin_router.get("/task/{task_id}")
|
||||
async def get_task_status(
|
||||
task_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
db = ray_actor_hook("postgres_database").postgres_database
|
||||
task = await db.get_org_task.remote(task_id)
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
return task
|
||||
|
||||
|
||||
@plugin_router.get("/task/{task_id}/events")
|
||||
async def get_task_events(
|
||||
task_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
db = ray_actor_hook("postgres_database").postgres_database
|
||||
events = await db.query_org_events.remote(task_id)
|
||||
return {"events": events}
|
||||
|
||||
|
||||
@plugin_router.get("/task/{task_id}/stream")
|
||||
async def stream_task(
|
||||
task_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
import asyncio
|
||||
|
||||
org_name = None
|
||||
db = ray_actor_hook("postgres_database").postgres_database
|
||||
task = await db.get_org_task.remote(task_id)
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
org_name = task["org_name"]
|
||||
|
||||
org = ray_actor_hook(f"org_{org_name}").get(f"org_{org_name}")
|
||||
|
||||
async def _generate():
|
||||
async for event in await org.stream.remote(task_id):
|
||||
yield f"data: {event}\n\n"
|
||||
|
||||
return StreamingResponse(_generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@plugin_router.get("/list")
|
||||
async def list_plugins(
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
|
||||
plugins = await pm.list_plugins.remote()
|
||||
return {"plugins": plugins}
|
||||
|
||||
|
||||
@plugin_router.post("/install")
|
||||
async def install_plugin(
|
||||
name: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
|
||||
await pm.install.remote(name)
|
||||
return {"status": "ok", "name": name}
|
||||
|
||||
|
||||
@plugin_router.post("/reload/{name}")
|
||||
async def reload_plugin(
|
||||
name: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
|
||||
await pm.reload.remote(name)
|
||||
return {"status": "ok", "name": name}
|
||||
@@ -15,8 +15,7 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, Dict, Literal
|
||||
from kilostar.utils.access import TokenData, Accessor
|
||||
from kilostar.utils.check_user.role_check import RoleChecker
|
||||
from kilostar.utils.access import TokenData, Accessor, RoleChecker
|
||||
from kilostar.core.postgres_database.model import UserAuthority
|
||||
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
@@ -17,10 +17,11 @@ from pydantic import BaseModel
|
||||
import viceroy
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from kilostar.utils.access import TokenData
|
||||
from kilostar.utils.check_user.role_check import RoleChecker
|
||||
from fastapi.responses import FileResponse
|
||||
from kilostar.utils.access import TokenData, RoleChecker, Accessor
|
||||
from kilostar.core.postgres_database.model import UserAuthority
|
||||
from kilostar.utils.mcp_helper import list_mcp_tools_from_gsm
|
||||
from kilostar.utils.settings import get_artifact_dir
|
||||
|
||||
resource_router = APIRouter(prefix="/api/v1/resource")
|
||||
|
||||
@@ -48,13 +49,12 @@ class MCPServerConfig(BaseModel):
|
||||
async def install_skill(
|
||||
skill: Skill, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))
|
||||
):
|
||||
"""通过 viceroy 把 skill 仓库克隆到 ``plugin/skill``,并在状态机中登记。"""
|
||||
"""通过 viceroy 把 skill 仓库克隆到 ``data/plugin/skill``,并在状态机中登记。"""
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
import os
|
||||
from kilostar.utils.settings import get_plugin_dir
|
||||
|
||||
skill_output_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "plugin", "skill")
|
||||
)
|
||||
skill_output_dir = str(get_plugin_dir() / "skill")
|
||||
os.makedirs(skill_output_dir, exist_ok=True)
|
||||
await viceroy.install_skill_async(
|
||||
url=skill.repo_url, path=skill.path, output=skill_output_dir
|
||||
@@ -133,6 +133,78 @@ async def delete_mcp_server(
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
# ─── Workflow Artifact 下载(agent send_file 投递的文件)───
|
||||
|
||||
|
||||
@resource_router.get("/artifact/{trace_id}/{artifact_id}")
|
||||
async def download_artifact(
|
||||
trace_id: str,
|
||||
artifact_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
"""下载某个 trace 名下的 agent 产物文件。
|
||||
|
||||
路径校验三件套:
|
||||
1. trace 必须存在且属于当前用户
|
||||
2. ``artifact_id`` 限定为 12 位 hex(uuid4 前缀),防止穿越
|
||||
3. 解析后的最终路径必须仍然落在 ``<artifact_dir>/<trace_id>/`` 之内
|
||||
"""
|
||||
if not artifact_id.isalnum() or len(artifact_id) > 32:
|
||||
raise HTTPException(status_code=400, detail="invalid artifact id")
|
||||
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
wf = await postgres_database.get_workflow.remote(trace_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
if getattr(wf, "user_id", None) != token_data.user_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
trace_dir = (get_artifact_dir() / trace_id).resolve()
|
||||
if not trace_dir.exists() or not trace_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
matches = list(trace_dir.glob(f"{artifact_id}_*"))
|
||||
if not matches:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
target = matches[0].resolve()
|
||||
if not str(target).startswith(str(trace_dir) + "/"):
|
||||
raise HTTPException(status_code=400, detail="invalid path")
|
||||
|
||||
filename = target.name.split("_", 1)[-1]
|
||||
return FileResponse(
|
||||
path=str(target),
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
# ─── Toolset Packages(磁盘工具包:插件单元)───
|
||||
|
||||
|
||||
@resource_router.get("/toolset-package")
|
||||
async def list_toolset_packages(
|
||||
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
|
||||
):
|
||||
"""列出所有磁盘上的工具包(``data/toolset/<name>/`` 单元)。"""
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
packages = await global_state_machine.list_toolset_packages.remote()
|
||||
return {"packages": packages}
|
||||
|
||||
|
||||
@resource_router.get("/toolset-package/{name}/readme")
|
||||
async def get_toolset_package_readme(
|
||||
name: str,
|
||||
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
|
||||
):
|
||||
"""返回指定工具包的 README.md 内容(markdown 文本)。"""
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
content = await global_state_machine.get_toolset_package_readme.remote(name)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="README not found")
|
||||
return {"name": name, "content": content}
|
||||
|
||||
|
||||
# ─── Tool Management ───
|
||||
|
||||
@resource_router.get("/tool")
|
||||
@@ -256,7 +328,7 @@ async def _assert_toolset_owner_or_admin(
|
||||
toolset: Dict[str, Any], token_data: TokenData
|
||||
) -> None:
|
||||
"""校验 toolset 归属:非 owner 且非管理员则抛 403。"""
|
||||
from kilostar.utils.check_user.role_check import get_authority
|
||||
from kilostar.utils.access import get_authority
|
||||
|
||||
if toolset.get("owner_id") == token_data.user_id:
|
||||
return
|
||||
@@ -294,7 +366,7 @@ async def list_custom_toolsets(
|
||||
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
|
||||
):
|
||||
"""列出工具组:支持按 category 过滤。USER 只能看到自己的+系统的;ADMIN 看全部。"""
|
||||
from kilostar.utils.check_user.role_check import get_authority
|
||||
from kilostar.utils.access import get_authority
|
||||
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
toolsets = await global_state_machine.list_custom_toolsets.remote()
|
||||
|
||||
@@ -25,8 +25,7 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.check_user.role_check import RoleChecker
|
||||
from kilostar.utils.access import Accessor, TokenData, RoleChecker
|
||||
from kilostar.core.postgres_database.model import UserAuthority
|
||||
from kilostar.utils.config_loader import (
|
||||
get_workflow_config,
|
||||
|
||||
@@ -18,8 +18,7 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from ulid import ULID
|
||||
import asyncio
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.check_user.role_check import RoleChecker
|
||||
from kilostar.utils.access import Accessor, TokenData, RoleChecker
|
||||
from kilostar.core.postgres_database.model import UserAuthority
|
||||
|
||||
workflow_router = APIRouter(prefix="/api/v1/workflow", tags=["workflow"])
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from kilostar.utils.standalone_proxy import actor_class, _STANDALONE
|
||||
from kilostar.utils.ray_compat import actor_class, _STANDALONE
|
||||
|
||||
if not _STANDALONE:
|
||||
import ray
|
||||
@@ -77,47 +77,40 @@ class GlobalStateMachine:
|
||||
# 启动期一次性发布 v1 快照,让等待中的读端立刻可用
|
||||
self._publish_snapshot()
|
||||
|
||||
_SYSTEM_TOOLSETS = [
|
||||
{
|
||||
"toolset_id": "system_basic",
|
||||
"name": "系统基础工具集",
|
||||
"description": "文件读写、搜索、代码执行等基础能力",
|
||||
"tools": ["file_reader", "write_file", "edit_file", "search_file", "python_executor", "shell_executor"],
|
||||
"is_system": True,
|
||||
"category": "system_basic",
|
||||
},
|
||||
{
|
||||
"toolset_id": "system_chat",
|
||||
"name": "系统对话工具集",
|
||||
"description": "对话场景专用工具(发送文件等)",
|
||||
"tools": ["send_file"],
|
||||
"is_system": True,
|
||||
"category": "system_chat",
|
||||
},
|
||||
{
|
||||
"toolset_id": "system_workflow",
|
||||
"name": "系统工作流工具集",
|
||||
"description": "工作流场景专用工具(审批、发送文件等)",
|
||||
"tools": ["approval", "send_file"],
|
||||
"is_system": True,
|
||||
"category": "system_workflow",
|
||||
},
|
||||
]
|
||||
|
||||
async def _seed_system_toolsets(self):
|
||||
"""若 DB 中缺少系统预置工具集则自动补种。"""
|
||||
for seed in self._SYSTEM_TOOLSETS:
|
||||
if seed["toolset_id"] not in self._custom_toolsets:
|
||||
await self.postgres_database.upsert_custom_toolset.remote(
|
||||
toolset_id=seed["toolset_id"],
|
||||
name=seed["name"],
|
||||
tools=seed["tools"],
|
||||
description=seed["description"],
|
||||
owner_id=None,
|
||||
is_system=True,
|
||||
category=seed["category"],
|
||||
)
|
||||
self._custom_toolsets[seed["toolset_id"]] = seed
|
||||
"""把磁盘上每个 toolset 包同步成一个 system custom_toolset 记录。
|
||||
|
||||
toolset 包就是插件单元——目录结构 ``data/toolset/<name>/`` 即代表一个工具集。
|
||||
启动时把每个包"投影"成一条 ``is_system=True`` 的 custom_toolset,
|
||||
前端工具插件界面看到的卡片就是这些包;将来安装第三方插件 = 把目录扔进去。
|
||||
|
||||
旧版本写死过 ``system_basic`` / ``system_chat`` / ``system_workflow`` 这种
|
||||
逻辑分组,这里会一并清理掉,避免遗留脏数据。
|
||||
"""
|
||||
packages = self._global_tool_manager.toolset_packages
|
||||
wanted_ids = {f"system::{name}" for name in packages.keys()}
|
||||
|
||||
# 清理 stale 系统 toolset(包括旧版硬编码的 system_basic/system_chat/...)
|
||||
for tid, ts in list(self._custom_toolsets.items()):
|
||||
if not ts.get("is_system"):
|
||||
continue
|
||||
if tid in wanted_ids:
|
||||
continue
|
||||
await self.postgres_database.delete_custom_toolset.remote(tid)
|
||||
self._custom_toolsets.pop(tid, None)
|
||||
|
||||
for name, pkg in packages.items():
|
||||
tid = f"system::{name}"
|
||||
saved = await self.postgres_database.upsert_custom_toolset.remote(
|
||||
toolset_id=tid,
|
||||
name=pkg.get("display_name") or name,
|
||||
tools=list(pkg.get("tools", [])),
|
||||
description=pkg.get("description") or None,
|
||||
owner_id=None,
|
||||
is_system=True,
|
||||
category=name,
|
||||
)
|
||||
self._custom_toolsets[tid] = saved
|
||||
|
||||
# ─── Snapshot 发布(Object Store 读路径) ────────────────────
|
||||
|
||||
@@ -254,6 +247,24 @@ class GlobalStateMachine:
|
||||
"""仅返回 retrieval 工具集(system_node 专用,不包含 generation 工具)。"""
|
||||
return self._global_tool_manager.get_retrieval_toolsets_for_scope(scope)
|
||||
|
||||
def list_toolset_packages(self) -> List[Dict[str, Any]]:
|
||||
"""列出所有磁盘工具包(前端"工具插件"页面卡片即由此渲染)。"""
|
||||
return [
|
||||
{k: v for k, v in pkg.items() if k != "readme_path"}
|
||||
for pkg in self._global_tool_manager.toolset_packages.values()
|
||||
]
|
||||
|
||||
def get_toolset_package_readme(self, name: str) -> Optional[str]:
|
||||
"""读取指定工具包的 README.md 内容;不存在返回 None。"""
|
||||
pkg = self._global_tool_manager.toolset_packages.get(name)
|
||||
if not pkg or not pkg.get("readme_path"):
|
||||
return None
|
||||
try:
|
||||
with open(pkg["readme_path"], "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ─── MCP Server Registry ───────────────────────────────────
|
||||
|
||||
async def add_mcp_server(self, server_id: str, config: Dict[str, Any]) -> bool:
|
||||
|
||||
@@ -33,7 +33,7 @@ import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from kilostar.utils.standalone_proxy import _STANDALONE
|
||||
from kilostar.utils.ray_compat import _STANDALONE
|
||||
|
||||
if not _STANDALONE:
|
||||
import ray
|
||||
@@ -63,7 +63,7 @@ class GSMSnapshot:
|
||||
tool_metadata: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
tool_funcs: Dict[str, Callable[..., Any]] = field(default_factory=dict)
|
||||
third_party_funcs: Dict[str, Callable[..., Any]] = field(default_factory=dict)
|
||||
tool_mapper: Dict[str, Dict[str, type]] = field(default_factory=dict)
|
||||
tool_mapper: Dict[str, Dict[str, Callable[..., Any]]] = field(default_factory=dict)
|
||||
# ``{scope: [tool_name, ...]}``:系统工具按 scope 维护的工具名清单。
|
||||
# 客户端按名字 + ``tool_funcs`` 在自己进程里重建 FunctionToolset,
|
||||
# 避开把不可序列化/版本耦合的 toolset 实例塞进快照的坑。
|
||||
|
||||
@@ -17,9 +17,11 @@ from collections import defaultdict
|
||||
import pathlib
|
||||
import json
|
||||
|
||||
from kilostar.utils.settings import get_plugin_dir
|
||||
|
||||
|
||||
class GlobalSkillManager:
|
||||
"""Skill 注册表:从 ``kilostar/plugin/skill/<name>/skill.json`` 启动期一次性扫描加载。"""
|
||||
"""Skill 注册表:从 ``data/plugin/skill/<name>/skill.json`` 启动期一次性扫描加载。"""
|
||||
|
||||
skill_mapper = Dict[str, Tuple[str]]
|
||||
"""skill的存储表"""
|
||||
@@ -27,23 +29,16 @@ class GlobalSkillManager:
|
||||
def __init__(self):
|
||||
self.skill_mapper = defaultdict(tuple)
|
||||
|
||||
import os
|
||||
|
||||
skill_plugin_dir = pathlib.Path(
|
||||
os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "plugin", "skill")
|
||||
)
|
||||
)
|
||||
skill_plugin_dir = get_plugin_dir() / "skill"
|
||||
if not skill_plugin_dir.exists() or not skill_plugin_dir.is_dir():
|
||||
return
|
||||
for item in skill_plugin_dir.iterdir():
|
||||
if item.is_dir() and not item.name.startswith((".", "__")):
|
||||
json_path = item / "skill.json" # 拼接文件路径
|
||||
json_path = item / "skill.json"
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
skill = json.load(f)
|
||||
# 提取并映射
|
||||
name = skill.get("name")
|
||||
if name:
|
||||
self.skill_mapper[name] = (
|
||||
@@ -55,13 +50,7 @@ class GlobalSkillManager:
|
||||
|
||||
def add_skill(self, skill_name: str) -> None:
|
||||
"""Add a skill to the manager by reading its skill.json from the path"""
|
||||
import os
|
||||
|
||||
skill_plugin_dir = pathlib.Path(
|
||||
os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "plugin", "skill")
|
||||
)
|
||||
)
|
||||
skill_plugin_dir = get_plugin_dir() / "skill"
|
||||
item = skill_plugin_dir / skill_name
|
||||
if item.is_dir() and not item.name.startswith((".", "__")):
|
||||
json_path = item / "skill.json"
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import pathlib
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable, Dict, List, Type
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
from kilostar.utils.settings import get_toolset_dir
|
||||
from kilostar.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("tool_manager")
|
||||
|
||||
_SYSTEM_BUCKET = "system"
|
||||
|
||||
def _bootstrap_toolset_modules():
|
||||
"""在 sys.modules 中注册 toolset 的虚拟包层级,使 toolset 内部的相对 import 能正常工作。"""
|
||||
toolset_dir = get_toolset_dir()
|
||||
|
||||
for pkg_name, pkg_path in [
|
||||
("data", toolset_dir.parent),
|
||||
("data.toolset", toolset_dir),
|
||||
]:
|
||||
if pkg_name not in sys.modules:
|
||||
mod = types.ModuleType(pkg_name)
|
||||
mod.__path__ = [str(pkg_path)]
|
||||
mod.__package__ = pkg_name
|
||||
sys.modules[pkg_name] = mod
|
||||
|
||||
|
||||
class GlobalToolManager:
|
||||
"""工具注册表:扫描 ``kilostar/plugin/tool_plugin/`` 下所有 BaseToolData 子类,
|
||||
按 ``action_scope`` 打包成 ``FunctionToolset``。
|
||||
"""工具注册表:扫描 ``data/toolset/`` 下所有 toolset 的 manifest.json,
|
||||
按 ``action_scope`` / ``is_system`` / ``category`` 分桶。
|
||||
|
||||
三类 toolset:
|
||||
- **system**:``is_system=True`` 的工具,按 scope 分组
|
||||
- **custom**:用户自定义工具组(由 ``rebuild_custom_toolsets`` 动态构建)
|
||||
- **mcp**:由 ``mcp_helper`` 独立管理,不经过本类
|
||||
|
||||
``category="mcp"`` 的工具不会被本类管理。
|
||||
"""
|
||||
|
||||
tool_metadata: Dict[str, Dict[str, Any]]
|
||||
@@ -30,7 +42,7 @@ class GlobalToolManager:
|
||||
_custom_toolsets: Dict[str, Any]
|
||||
_third_party_funcs: Dict[str, Callable]
|
||||
_all_funcs: Dict[str, Callable]
|
||||
tool_mapper: Dict[str, Dict[str, Type[BaseToolData]]]
|
||||
toolset_packages: Dict[str, Dict[str, Any]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.tool_metadata = {}
|
||||
@@ -41,75 +53,126 @@ class GlobalToolManager:
|
||||
self._custom_toolsets = {}
|
||||
self._third_party_funcs = {}
|
||||
self._all_funcs = {}
|
||||
self.tool_mapper = defaultdict(dict)
|
||||
self.toolset_packages = {}
|
||||
|
||||
tool_plugin_dir = (
|
||||
pathlib.Path(__file__).parent.parent.parent / "plugin" / "tool_plugin"
|
||||
)
|
||||
if not tool_plugin_dir.exists() or not tool_plugin_dir.is_dir():
|
||||
_bootstrap_toolset_modules()
|
||||
|
||||
toolset_root = get_toolset_dir()
|
||||
if not toolset_root.exists() or not toolset_root.is_dir():
|
||||
return
|
||||
|
||||
for item in tool_plugin_dir.iterdir():
|
||||
if not (item.is_dir() and not item.name.startswith("__")):
|
||||
for toolset_dir in toolset_root.iterdir():
|
||||
if not toolset_dir.is_dir() or toolset_dir.name.startswith("__"):
|
||||
continue
|
||||
plugin_name = item.name
|
||||
module_name = f"kilostar.plugin.tool_plugin.{plugin_name}"
|
||||
manifest_path = toolset_dir / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
self._load_toolset(toolset_dir, manifest_path)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to import tool plugin {plugin_name}: {e}")
|
||||
self._build_system_toolsets()
|
||||
self._build_retrieval_toolsets()
|
||||
|
||||
def _load_toolset(self, toolset_dir, manifest_path) -> None:
|
||||
"""从一个 toolset 目录加载 manifest 并注册所有工具。"""
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read manifest {manifest_path}: {e}")
|
||||
return
|
||||
|
||||
toolset_name = toolset_dir.name
|
||||
|
||||
# 注册 toolset 的虚拟包
|
||||
pkg_name = f"data.toolset.{toolset_name}"
|
||||
if pkg_name not in sys.modules:
|
||||
mod = types.ModuleType(pkg_name)
|
||||
mod.__path__ = [str(toolset_dir)]
|
||||
mod.__package__ = pkg_name
|
||||
sys.modules[pkg_name] = mod
|
||||
|
||||
registered_tools: List[str] = []
|
||||
|
||||
for tool_def in manifest.get("tools", []):
|
||||
tool_name = tool_def.get("name")
|
||||
tool_file = tool_def.get("file", f"{tool_name}.py")
|
||||
if not tool_name:
|
||||
continue
|
||||
|
||||
tool_data_cls = self._find_tool_data_class(module)
|
||||
if tool_data_cls is None:
|
||||
file_path = toolset_dir / tool_file
|
||||
if not file_path.exists():
|
||||
logger.warning(f"Tool file not found: {file_path}")
|
||||
continue
|
||||
|
||||
tool_func = getattr(module, plugin_name, None)
|
||||
if not callable(tool_func):
|
||||
logger.warning(
|
||||
f"Tool plugin '{plugin_name}' has no callable named "
|
||||
f"'{plugin_name}' in its module; skipped."
|
||||
)
|
||||
tool_func = self._load_tool_func(toolset_name, tool_name, file_path)
|
||||
if tool_func is None:
|
||||
continue
|
||||
|
||||
action_scopes = (
|
||||
tool_data_cls.model_fields.get("action_scope").default or []
|
||||
)
|
||||
is_system = bool(tool_data_cls.model_fields.get("is_system").default)
|
||||
category_field = tool_data_cls.model_fields.get("category")
|
||||
category = (category_field.default if category_field else "other") or "other"
|
||||
toolset_field = tool_data_cls.model_fields.get("toolset")
|
||||
toolset_name = (toolset_field.default if toolset_field else "other") or "other"
|
||||
is_system = tool_def.get("is_system", True)
|
||||
action_scopes = tool_def.get("action_scope", [])
|
||||
category = tool_def.get("category", "other")
|
||||
config_args = tool_def.get("config_args", {})
|
||||
toolset_field = tool_def.get("toolset", "other")
|
||||
|
||||
self.tool_metadata[plugin_name] = {
|
||||
"name": plugin_name,
|
||||
self.tool_metadata[tool_name] = {
|
||||
"name": tool_name,
|
||||
"is_system": is_system,
|
||||
"category": category,
|
||||
"toolset": toolset_name,
|
||||
"toolset": toolset_field,
|
||||
"action_scope": list(action_scopes),
|
||||
"config_args": config_args,
|
||||
"source_toolset": toolset_name,
|
||||
}
|
||||
registered_tools.append(tool_name)
|
||||
|
||||
if category == "mcp":
|
||||
continue
|
||||
|
||||
self._all_funcs[plugin_name] = tool_func
|
||||
self._all_funcs[tool_name] = tool_func
|
||||
|
||||
scopes = [s for s in action_scopes if s] or ["default"]
|
||||
|
||||
if is_system:
|
||||
for scope in scopes:
|
||||
self._tool_funcs[scope][plugin_name] = tool_func
|
||||
self.tool_mapper[scope][plugin_name] = tool_data_cls
|
||||
if toolset_name == "retrieval":
|
||||
self._retrieval_tool_funcs[scope][plugin_name] = tool_func
|
||||
self._tool_funcs[scope][tool_name] = tool_func
|
||||
if toolset_field == "retrieval":
|
||||
self._retrieval_tool_funcs[scope][tool_name] = tool_func
|
||||
else:
|
||||
self._third_party_funcs[plugin_name] = tool_func
|
||||
for scope in scopes:
|
||||
self.tool_mapper[scope][plugin_name] = tool_data_cls
|
||||
self._third_party_funcs[tool_name] = tool_func
|
||||
|
||||
self._build_system_toolsets()
|
||||
self._build_retrieval_toolsets()
|
||||
readme_path = toolset_dir / "README.md"
|
||||
self.toolset_packages[toolset_name] = {
|
||||
"name": toolset_name,
|
||||
"display_name": manifest.get("name", toolset_name),
|
||||
"version": manifest.get("version", ""),
|
||||
"description": manifest.get("description", ""),
|
||||
"tools": registered_tools,
|
||||
"has_readme": readme_path.exists(),
|
||||
"readme_path": str(readme_path) if readme_path.exists() else None,
|
||||
}
|
||||
|
||||
def _load_tool_func(self, toolset_name: str, tool_name: str, file_path) -> Callable | None:
|
||||
"""从文件加载工具函数。"""
|
||||
module_name = f"data.toolset.{toolset_name}.{tool_name}"
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
|
||||
if spec is None or spec.loader is None:
|
||||
logger.warning(f"Failed to create spec for {module_name}")
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
func = getattr(module, tool_name, None)
|
||||
if not callable(func):
|
||||
logger.warning(
|
||||
f"Tool '{tool_name}' has no callable named '{tool_name}' in {file_path}"
|
||||
)
|
||||
return None
|
||||
return func
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to import tool {tool_name}: {e}")
|
||||
return None
|
||||
|
||||
def _build_system_toolsets(self) -> None:
|
||||
FunctionToolset = self._import_function_toolset()
|
||||
@@ -142,11 +205,7 @@ class GlobalToolManager:
|
||||
logger.error(f"Failed to build retrieval toolset {scope}: {e}")
|
||||
|
||||
def rebuild_custom_toolsets(self, custom_defs: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""根据 DB 中的 toolset 定义重建 FunctionToolset。
|
||||
|
||||
系统 toolset(is_system=True)允许包含 system 工具,用户 toolset 只取得到 callable
|
||||
的工具(理论上业务层已校验只包含第三方工具)。
|
||||
"""
|
||||
"""根据 DB 中的 toolset 定义重建 FunctionToolset。"""
|
||||
FunctionToolset = self._import_function_toolset()
|
||||
if FunctionToolset is None:
|
||||
self._custom_toolsets = {}
|
||||
@@ -178,15 +237,13 @@ class GlobalToolManager:
|
||||
logger.warning("pydantic_ai.toolsets unavailable")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_tool_data_class(module) -> Type[BaseToolData] | None:
|
||||
for _, obj in inspect.getmembers(module, inspect.isclass):
|
||||
if issubclass(obj, BaseToolData) and obj is not BaseToolData:
|
||||
return obj
|
||||
return None
|
||||
|
||||
# ─── Toolset accessors ───
|
||||
|
||||
@property
|
||||
def tool_mapper(self) -> Dict[str, Dict[str, Callable]]:
|
||||
"""scope → {tool_name: callable},兼容 GSM 快照构建。"""
|
||||
return dict(self._tool_funcs)
|
||||
|
||||
def get_system_toolset(self, scope: str) -> Any | None:
|
||||
return self._system_toolsets.get(scope)
|
||||
|
||||
@@ -230,7 +287,6 @@ class GlobalToolManager:
|
||||
def get_all_tools(self) -> List[Dict[str, Any]]:
|
||||
return list(self.tool_metadata.values())
|
||||
|
||||
# 兼容旧接口
|
||||
def get_non_system_tools(self) -> List[Dict[str, Any]]:
|
||||
return self.get_third_party_tools()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
from kilostar.utils.standalone_proxy import actor_class
|
||||
from kilostar.utils.ray_compat import actor_class
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from kilostar.utils.logger import get_logger
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
|
||||
from typing import Union, overload
|
||||
from kilostar.utils.standalone_proxy import actor_class
|
||||
from kilostar.utils.ray_compat import actor_class
|
||||
from kilostar.core.individual.consciousness_node.template import (
|
||||
ConsciousnessNodeDeps,
|
||||
ForregulatoryNode,
|
||||
@@ -29,7 +29,7 @@ from kilostar.core.global_state_machine.global_state_machine import GlobalStateM
|
||||
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
|
||||
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from kilostar.utils.i18n import agent_prompt
|
||||
from kilostar.utils.prompts import agent_prompt
|
||||
|
||||
|
||||
@actor_class
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from kilostar.utils.standalone_proxy import actor_class
|
||||
from kilostar.utils.ray_compat import actor_class
|
||||
from kilostar.core.global_state_machine.global_state_machine import GlobalStateMachine
|
||||
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
|
||||
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
||||
@@ -22,7 +22,7 @@ from kilostar.core.individual.control_node.template import (
|
||||
ForWorkflowInput,
|
||||
ControlNodeDeps,
|
||||
)
|
||||
from kilostar.utils.i18n import agent_prompt
|
||||
from kilostar.utils.prompts import agent_prompt
|
||||
|
||||
|
||||
@actor_class
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from typing import Union
|
||||
from kilostar.utils.standalone_proxy import actor_class
|
||||
from kilostar.utils.ray_compat import actor_class
|
||||
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
||||
from kilostar.core.global_state_machine.global_state_machine import GlobalStateMachine
|
||||
from kilostar.core.global_state_machine.model_provider import Provider
|
||||
@@ -25,7 +25,7 @@ from kilostar.core.individual.regulatory_node.template import (
|
||||
MessageResponse
|
||||
)
|
||||
from pydantic_ai import RunContext, Agent
|
||||
from kilostar.utils.i18n import agent_prompt
|
||||
from kilostar.utils.prompts import agent_prompt
|
||||
|
||||
|
||||
@actor_class
|
||||
@@ -111,15 +111,20 @@ class RegulatoryNode:
|
||||
)
|
||||
return prompt
|
||||
|
||||
async def working(self, payload: MessageRequest) -> Union[MessageResponse, None]:
|
||||
async def working(
|
||||
self,
|
||||
payload: MessageRequest,
|
||||
message_history: list | None = None,
|
||||
) -> Union[MessageResponse, None]:
|
||||
"""working方法,是节点唯一的调用方法,对_run函数的结果进行判断并返回最终回复
|
||||
Args:
|
||||
payload: 消息载荷,包含所有信息
|
||||
message_history: pydantic-ai ``ModelMessage`` 列表,传入历史让多轮对话连贯
|
||||
|
||||
Returns:
|
||||
MessageResponse 或 None,监控节点对用户的结构化回复
|
||||
"""
|
||||
return await self._run(payload)
|
||||
return await self._run(payload, message_history=message_history)
|
||||
|
||||
_CHAT_INSTRUCTIONS = (
|
||||
"你是 kilostar 智能助手。你现在处于【直接对话模式】,请直接回答用户的问题。\n"
|
||||
@@ -130,7 +135,12 @@ class RegulatoryNode:
|
||||
"4. 回复应当完整、有帮助,避免过于简短。\n"
|
||||
)
|
||||
|
||||
async def stream_working(self, payload: MessageRequest, token_queue: "asyncio.Queue") -> None:
|
||||
async def stream_working(
|
||||
self,
|
||||
payload: MessageRequest,
|
||||
token_queue: "asyncio.Queue",
|
||||
message_history: list | None = None,
|
||||
) -> None:
|
||||
"""流式对话:完整执行 agent graph(含工具调用),逐 token 推送文本到 queue。
|
||||
|
||||
使用 event_stream_handler 回调拿到每个 text delta,保证工具调用后
|
||||
@@ -167,6 +177,7 @@ class RegulatoryNode:
|
||||
output_type=str,
|
||||
instructions=self._CHAT_INSTRUCTIONS,
|
||||
event_stream_handler=_stream_handler,
|
||||
message_history=message_history,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"RegulatoryNode.stream_working failed: {e}")
|
||||
@@ -175,7 +186,9 @@ class RegulatoryNode:
|
||||
await token_queue.put(None)
|
||||
|
||||
async def _run(
|
||||
self, payload: MessageRequest
|
||||
self,
|
||||
payload: MessageRequest,
|
||||
message_history: list | None = None,
|
||||
) -> Union[MessageResponse, None]:
|
||||
platform = payload.platform
|
||||
user_name = payload.user_name
|
||||
@@ -187,8 +200,11 @@ class RegulatoryNode:
|
||||
user_name=user_name,
|
||||
time=time_str
|
||||
)
|
||||
agent_response = await self.agent.run(user_prompt=message,
|
||||
deps=deps,)
|
||||
agent_response = await self.agent.run(
|
||||
user_prompt=message,
|
||||
deps=deps,
|
||||
message_history=message_history,
|
||||
)
|
||||
response: MessageResponse = agent_response.output
|
||||
response.platform = platform
|
||||
response.platform_id = payload.platform_id
|
||||
|
||||
@@ -35,6 +35,8 @@ from kilostar.core.postgres_database.model.tool_config import ToolConfigModel
|
||||
from kilostar.core.postgres_database.model.custom_toolset import CustomToolsetModel
|
||||
from kilostar.core.postgres_database.model.system_event_log import SystemEventLog
|
||||
from kilostar.core.postgres_database.model.persona_template import PersonaTemplate
|
||||
from kilostar.core.postgres_database.model.org_task import OrgTask
|
||||
from kilostar.core.postgres_database.model.org_task_event import OrgTaskEvent
|
||||
|
||||
# 兼容旧代码的别名
|
||||
Provider = ProviderModel
|
||||
@@ -65,5 +67,7 @@ __all__ = [
|
||||
"CustomToolsetModel",
|
||||
"SystemEventLog",
|
||||
"PersonaTemplate",
|
||||
"OrgTask",
|
||||
"OrgTaskEvent",
|
||||
"AgentType",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import String, DateTime, Integer, func, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from .base import BaseDataModel
|
||||
|
||||
|
||||
class OrgTask(BaseDataModel):
|
||||
__tablename__ = "org_task"
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
)
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(64), unique=True, index=True, comment="外部任务 ID(UUID)"
|
||||
)
|
||||
org_name: Mapped[str] = mapped_column(
|
||||
String(128), index=True, comment="所属组织/插件名"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), index=True, default="pending",
|
||||
comment="pending/running/done/error"
|
||||
)
|
||||
description: Mapped[str] = mapped_column(
|
||||
Text, comment="任务描述"
|
||||
)
|
||||
result: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="最终结果"
|
||||
)
|
||||
context: Mapped[dict | None] = mapped_column(
|
||||
JSONB, nullable=True, comment="调用上下文"
|
||||
)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
updated_at: Mapped[str] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(),
|
||||
onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import String, DateTime, Integer, func, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from .base import BaseDataModel
|
||||
|
||||
|
||||
class OrgTaskEvent(BaseDataModel):
|
||||
__tablename__ = "org_task_event"
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
)
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(64), index=True, comment="关联的 org_task.task_id"
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(
|
||||
String(30), index=True,
|
||||
comment="log/step/artifact/approval_request/done/error"
|
||||
)
|
||||
payload: Mapped[dict | None] = mapped_column(
|
||||
JSONB, nullable=True, comment="事件负载"
|
||||
)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select, desc, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||
|
||||
from kilostar.core.postgres_database.model.org_task import OrgTask
|
||||
from kilostar.core.postgres_database.model.org_task_event import OrgTaskEvent
|
||||
from kilostar.core.postgres_database.database_exception import database_exception
|
||||
|
||||
|
||||
class OrgTaskDatabase:
|
||||
def __init__(self, async_session_maker: async_sessionmaker[AsyncSession]):
|
||||
self.async_session_maker = async_session_maker
|
||||
|
||||
@database_exception
|
||||
async def create_task(
|
||||
self,
|
||||
task_id: str,
|
||||
org_name: str,
|
||||
description: str,
|
||||
context: Optional[dict] = None,
|
||||
) -> None:
|
||||
async with self.async_session_maker() as session:
|
||||
task = OrgTask(
|
||||
task_id=task_id,
|
||||
org_name=org_name,
|
||||
description=description,
|
||||
context=context,
|
||||
status="pending",
|
||||
)
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
|
||||
@database_exception
|
||||
async def update_status(
|
||||
self, task_id: str, status: str, result: Optional[str] = None
|
||||
) -> None:
|
||||
async with self.async_session_maker() as session:
|
||||
stmt = (
|
||||
update(OrgTask)
|
||||
.where(OrgTask.task_id == task_id)
|
||||
.values(status=status, result=result)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
@database_exception
|
||||
async def get_task(self, task_id: str) -> Optional[dict]:
|
||||
async with self.async_session_maker() as session:
|
||||
stmt = select(OrgTask).where(OrgTask.task_id == task_id)
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"task_id": row.task_id,
|
||||
"org_name": row.org_name,
|
||||
"status": row.status,
|
||||
"description": row.description,
|
||||
"result": row.result,
|
||||
"context": row.context,
|
||||
"created_at": str(row.created_at) if row.created_at else None,
|
||||
"updated_at": str(row.updated_at) if row.updated_at else None,
|
||||
}
|
||||
|
||||
@database_exception
|
||||
async def list_tasks(
|
||||
self, org_name: Optional[str] = None, limit: int = 50, offset: int = 0
|
||||
) -> List[dict]:
|
||||
async with self.async_session_maker() as session:
|
||||
stmt = select(OrgTask).order_by(desc(OrgTask.created_at))
|
||||
if org_name:
|
||||
stmt = stmt.where(OrgTask.org_name == org_name)
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"task_id": r.task_id,
|
||||
"org_name": r.org_name,
|
||||
"status": r.status,
|
||||
"description": r.description,
|
||||
"created_at": str(r.created_at) if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@database_exception
|
||||
async def insert_event(
|
||||
self, task_id: str, event_type: str, payload: Optional[dict] = None
|
||||
) -> None:
|
||||
async with self.async_session_maker() as session:
|
||||
evt = OrgTaskEvent(
|
||||
task_id=task_id, event_type=event_type, payload=payload
|
||||
)
|
||||
session.add(evt)
|
||||
await session.commit()
|
||||
|
||||
@database_exception
|
||||
async def query_events(
|
||||
self, task_id: str, limit: int = 200
|
||||
) -> List[dict]:
|
||||
async with self.async_session_maker() as session:
|
||||
stmt = (
|
||||
select(OrgTaskEvent)
|
||||
.where(OrgTaskEvent.task_id == task_id)
|
||||
.order_by(OrgTaskEvent.created_at)
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"task_id": r.task_id,
|
||||
"event_type": r.event_type,
|
||||
"payload": r.payload,
|
||||
"created_at": str(r.created_at) if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -15,7 +15,7 @@
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
from kilostar.utils.standalone_proxy import actor_class
|
||||
from kilostar.utils.ray_compat import actor_class
|
||||
from kilostar.utils.settings import get_settings
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -44,6 +44,8 @@ from kilostar.core.postgres_database.model.tool_config import ToolConfigModel
|
||||
from kilostar.core.postgres_database.model.custom_toolset import CustomToolsetModel
|
||||
from kilostar.core.postgres_database.model.system_event_log import SystemEventLog
|
||||
from kilostar.core.postgres_database.model.persona_template import PersonaTemplate
|
||||
from kilostar.core.postgres_database.model.org_task import OrgTask
|
||||
from kilostar.core.postgres_database.model.org_task_event import OrgTaskEvent
|
||||
|
||||
from .module.individual import IndividualDatabase
|
||||
from .module.user import AuthDatabase
|
||||
@@ -56,6 +58,7 @@ from .module.tool_config import ToolConfigDatabase
|
||||
from .module.custom_toolset import CustomToolsetDatabase
|
||||
from .module.system_event_log import SystemEventLogDatabase
|
||||
from .module.persona_template import PersonaTemplateDatabase
|
||||
from .module.org_task import OrgTaskDatabase
|
||||
|
||||
|
||||
@actor_class
|
||||
@@ -89,6 +92,7 @@ class PostgresDatabase:
|
||||
self._custom_toolset_database = CustomToolsetDatabase(self.async_session_maker)
|
||||
self._system_event_log_database = SystemEventLogDatabase(self.async_session_maker)
|
||||
self._persona_template_database = PersonaTemplateDatabase(self.async_session_maker)
|
||||
self._org_task_database = OrgTaskDatabase(self.async_session_maker)
|
||||
|
||||
self.ready_event = asyncio.Event()
|
||||
|
||||
@@ -458,3 +462,28 @@ class PostgresDatabase:
|
||||
async def delete_template(self, template_id: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._persona_template_database.delete_template(template_id)
|
||||
|
||||
# Org Task Database Methods
|
||||
async def create_org_task(self, task_id: str, org_name: str, description: str, context=None):
|
||||
await self.ready_event.wait()
|
||||
return await self._org_task_database.create_task(task_id, org_name, description, context)
|
||||
|
||||
async def update_org_task_status(self, task_id: str, status: str, result=None):
|
||||
await self.ready_event.wait()
|
||||
return await self._org_task_database.update_status(task_id, status, result)
|
||||
|
||||
async def get_org_task(self, task_id: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._org_task_database.get_task(task_id)
|
||||
|
||||
async def list_org_tasks(self, org_name=None, limit=50, offset=0):
|
||||
await self.ready_event.wait()
|
||||
return await self._org_task_database.list_tasks(org_name, limit, offset)
|
||||
|
||||
async def insert_org_event(self, task_id: str, event_type: str, payload=None):
|
||||
await self.ready_event.wait()
|
||||
return await self._org_task_database.insert_event(task_id, event_type, payload)
|
||||
|
||||
async def query_org_events(self, task_id: str, limit=200):
|
||||
await self.ready_event.wait()
|
||||
return await self._org_task_database.query_events(task_id, limit)
|
||||
|
||||
@@ -36,7 +36,7 @@ import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from kilostar.utils.standalone_proxy import remote_task, _STANDALONE
|
||||
from kilostar.utils.ray_compat import remote_task, _STANDALONE
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_graph import BaseNode, End, Graph, GraphRunContext
|
||||
from pydantic_graph.persistence import BaseStatePersistence
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -1,13 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -1,17 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from .approval import ApprovalToolData, approval
|
||||
|
||||
__all__ = ["ApprovalToolData", "approval"]
|
||||
@@ -1,50 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from typing import List, Literal, Dict
|
||||
|
||||
|
||||
class ApprovalToolData(BaseToolData):
|
||||
"""``approval`` 工具的元数据:分配给所有系统节点和 skill_individual。"""
|
||||
|
||||
is_system: bool = True
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "system"
|
||||
|
||||
|
||||
async def approval(message: str, trace_id: str) -> str:
|
||||
"""
|
||||
当任务存在某些高风险操作或者计划需要让用户审批,发送请求给用户等待用户审批
|
||||
Args:
|
||||
message: 发送给用户的请求
|
||||
trace_id:
|
||||
|
||||
Returns:
|
||||
用户的审批结果
|
||||
"""
|
||||
actor_list = ray_actor_hook("global_workflow_manager")
|
||||
await actor_list.global_workflow_manager.put_pending.remote(trace_id, message)
|
||||
reply = await actor_list.global_workflow_manager.get_received.remote(trace_id)
|
||||
return reply
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Literal, Dict
|
||||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class BaseToolData(BaseModel):
|
||||
"""所有工具插件的基类:声明工具的作用域、是否系统级以及配置参数 schema。"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
is_system: bool
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "other"
|
||||
"""工具分类:system(系统内置)、search(搜索)、mcp(MCP 服务器)、other(其他)"""
|
||||
toolset: str = "other"
|
||||
"""工具集:retrieval(检索)、generation(生成)、other(其他)。system_node 只能用 retrieval 集。"""
|
||||
@@ -1,61 +0,0 @@
|
||||
import os
|
||||
from typing import List, Literal, Dict
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
|
||||
|
||||
class EditFileToolData(BaseToolData):
|
||||
is_system: bool = True
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "system"
|
||||
|
||||
|
||||
async def edit_file(
|
||||
file_path: str,
|
||||
old_content: str,
|
||||
new_content: str,
|
||||
) -> str:
|
||||
"""通过查找替换的方式编辑文件内容。
|
||||
|
||||
Args:
|
||||
file_path: 文件的路径
|
||||
old_content: 要被替换的原始内容片段
|
||||
new_content: 替换后的新内容
|
||||
|
||||
Returns:
|
||||
操作结果描述
|
||||
"""
|
||||
from kilostar.utils.sandbox import validate_path, PathViolation
|
||||
|
||||
try:
|
||||
file_path = validate_path(file_path, write=True)
|
||||
except PathViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
return f"[Error] 文件不存在: {file_path}"
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
if old_content not in content:
|
||||
return f"[Error] 未在文件中找到要替换的内容片段"
|
||||
|
||||
new_file_content = content.replace(old_content, new_content, 1)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_file_content)
|
||||
|
||||
return f"已成功编辑文件: {file_path}"
|
||||
except Exception as e:
|
||||
return f"[Error] 编辑文件失败: {e}"
|
||||
@@ -1,17 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from .file_reader import FileReaderToolData, file_reader
|
||||
|
||||
__all__ = ["FileReaderToolData", "file_reader"]
|
||||
@@ -1,63 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""File Reader Tool Plugin for KiloStar.
|
||||
|
||||
Reads the contents of a file from the local filesystem.
|
||||
"""
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
from typing import List, Literal, Dict
|
||||
|
||||
|
||||
class FileReaderToolData(BaseToolData):
|
||||
"""``file_reader`` 工具的元数据。"""
|
||||
|
||||
is_system: bool = True
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "system"
|
||||
|
||||
|
||||
async def file_reader(file_path: str) -> str:
|
||||
"""读取本地文件的内容。
|
||||
|
||||
Args:
|
||||
file_path: 文件的绝对路径或相对路径
|
||||
|
||||
Returns:
|
||||
文件内容文本,若文件不存在则返回错误信息
|
||||
"""
|
||||
from kilostar.utils.sandbox import validate_path, PathViolation
|
||||
|
||||
try:
|
||||
file_path = validate_path(file_path, write=False)
|
||||
except PathViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return f"[Error] File not found: {file_path}"
|
||||
except Exception as e:
|
||||
return f"[Error] Failed to read file: {str(e)}"
|
||||
@@ -1,77 +0,0 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import os
|
||||
from typing import List, Literal, Dict
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
|
||||
|
||||
class PythonExecutorToolData(BaseToolData):
|
||||
is_system: bool = True
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "system"
|
||||
|
||||
|
||||
async def python_executor(code: str, timeout: int = 30) -> str:
|
||||
"""执行 Python 代码片段并返回输出。
|
||||
|
||||
Args:
|
||||
code: 要执行的 Python 代码
|
||||
timeout: 超时秒数,默认 30 秒
|
||||
|
||||
Returns:
|
||||
代码的标准输出 + 标准错误
|
||||
"""
|
||||
from kilostar.utils.sandbox import (
|
||||
validate_python_code, CodeViolation, get_python_timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
code = validate_python_code(code)
|
||||
except CodeViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
timeout = get_python_timeout(timeout)
|
||||
|
||||
tmp_file = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(code)
|
||||
tmp_file = f.name
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable, tmp_file,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
err_output = stderr.decode("utf-8", errors="replace")
|
||||
result = ""
|
||||
if output:
|
||||
result += output
|
||||
if err_output:
|
||||
result += f"\n[stderr]\n{err_output}"
|
||||
if proc.returncode != 0:
|
||||
result += f"\n[exit code: {proc.returncode}]"
|
||||
return result.strip() or "(no output)"
|
||||
except asyncio.TimeoutError:
|
||||
return f"[Error] Python 代码执行超时({timeout}s)"
|
||||
except Exception as e:
|
||||
return f"[Error] 执行失败: {e}"
|
||||
finally:
|
||||
if tmp_file and os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
@@ -1,73 +0,0 @@
|
||||
import asyncio
|
||||
from typing import List, Literal, Dict
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
|
||||
|
||||
class SearchFileToolData(BaseToolData):
|
||||
is_system: bool = True
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "system"
|
||||
|
||||
|
||||
async def search_file(
|
||||
keyword: str,
|
||||
directory: str = ".",
|
||||
file_pattern: str = "*",
|
||||
max_results: int = 20,
|
||||
) -> str:
|
||||
"""在指定目录下递归搜索包含关键字的文件内容。
|
||||
|
||||
Args:
|
||||
keyword: 要搜索的关键字或正则表达式
|
||||
directory: 搜索的根目录,默认当前目录
|
||||
file_pattern: 文件名匹配模式,如 "*.py"
|
||||
max_results: 最大返回结果数
|
||||
|
||||
Returns:
|
||||
匹配的文件名和行内容
|
||||
"""
|
||||
from kilostar.utils.sandbox import validate_path, PathViolation
|
||||
|
||||
try:
|
||||
directory = validate_path(directory, write=False)
|
||||
except PathViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
max_results = min(max_results, 100)
|
||||
|
||||
try:
|
||||
grep_args = [
|
||||
"grep", "-rn",
|
||||
f"--include={file_pattern}",
|
||||
"-m", str(max_results),
|
||||
"--", keyword, directory,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*grep_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=30
|
||||
)
|
||||
output = stdout.decode("utf-8", errors="replace").strip()
|
||||
if not output:
|
||||
return f"未找到包含 '{keyword}' 的匹配项"
|
||||
lines = output.split("\n")
|
||||
if len(lines) > max_results:
|
||||
output = "\n".join(lines[:max_results])
|
||||
return output
|
||||
except asyncio.TimeoutError:
|
||||
return "[Error] 搜索超时"
|
||||
except Exception as e:
|
||||
return f"[Error] 搜索失败: {e}"
|
||||
@@ -1,17 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from .send_file import SendFileToolData, send_file
|
||||
|
||||
__all__ = ["SendFileToolData", "send_file"]
|
||||
@@ -1,65 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from typing import List, Literal, Dict
|
||||
|
||||
|
||||
class SendFileToolData(BaseToolData):
|
||||
"""``send_file`` 工具元数据:把 agent 生成的文件作为附件推送到对话窗口。"""
|
||||
|
||||
is_system: bool = True
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "system"
|
||||
|
||||
|
||||
async def send_file(filename: str, content: str, trace_id: str = "") -> str:
|
||||
"""把 agent 生成的文件作为附件发送给当前对话窗口。
|
||||
|
||||
工作流场景下调用方会在 deps 中注入 trace_id,工具通过 global_workflow_manager
|
||||
的 pending 队列推送一条带特殊前缀的 JSON 消息,前端识别后渲染为可下载的卡片。
|
||||
|
||||
聊天场景下 trace_id 为空时退化为直接返回文件内容字符串,由模型把内容贴到回复里。
|
||||
|
||||
Args:
|
||||
filename: 文件名(含扩展名),如 "report.md" / "main.py"
|
||||
content: 文件内容(UTF-8 文本)
|
||||
trace_id: 当前会话/工作流的 trace_id;为空时退化为直接返回内容
|
||||
|
||||
Returns:
|
||||
发送结果说明或文件内容
|
||||
"""
|
||||
if not trace_id:
|
||||
return f"文件 {filename} 内容如下:\n\n```\n{content}\n```"
|
||||
|
||||
payload = json.dumps(
|
||||
{"type": "file", "filename": filename, "content": content},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
actor_list = ray_actor_hook("global_workflow_manager")
|
||||
await actor_list.global_workflow_manager.put_pending.remote(
|
||||
trace_id, f"__FILE__{payload}"
|
||||
)
|
||||
return f"已发送文件: {filename}"
|
||||
@@ -1,64 +0,0 @@
|
||||
import asyncio
|
||||
from typing import List, Literal, Dict
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
|
||||
|
||||
class ShellExecutorToolData(BaseToolData):
|
||||
is_system: bool = True
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "system"
|
||||
|
||||
|
||||
async def shell_executor(command: str, timeout: int = 30) -> str:
|
||||
"""在服务器上执行 shell 命令并返回输出。
|
||||
|
||||
Args:
|
||||
command: 要执行的 shell 命令
|
||||
timeout: 超时秒数,默认 30 秒
|
||||
|
||||
Returns:
|
||||
命令的 stdout + stderr 输出
|
||||
"""
|
||||
from kilostar.utils.sandbox import (
|
||||
validate_shell_command, CommandViolation, get_shell_timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
command = validate_shell_command(command)
|
||||
except CommandViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
timeout = get_shell_timeout(timeout)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
err_output = stderr.decode("utf-8", errors="replace")
|
||||
result = ""
|
||||
if output:
|
||||
result += output
|
||||
if err_output:
|
||||
result += f"\n[stderr]\n{err_output}"
|
||||
if proc.returncode != 0:
|
||||
result += f"\n[exit code: {proc.returncode}]"
|
||||
return result.strip() or "(no output)"
|
||||
except asyncio.TimeoutError:
|
||||
return f"[Error] 命令执行超时({timeout}s)"
|
||||
except Exception as e:
|
||||
return f"[Error] 执行失败: {e}"
|
||||
@@ -1,122 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tavily Web Search Tool Plugin for KiloStar.
|
||||
|
||||
Provides intelligent web search capabilities via Tavily API.
|
||||
API key 取值优先级:调用参数 > GlobalStateMachine 中 ``tavily_search`` 工具配置 >
|
||||
环境变量 ``TAVILY_API_KEY``。
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List, Literal, Dict, Optional
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
from tavily import AsyncTavilyClient
|
||||
|
||||
|
||||
class TavilySearchToolData(BaseToolData):
|
||||
"""Tavily 搜索工具的元数据:面向所有节点开放。"""
|
||||
|
||||
is_system: bool = False
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
]
|
||||
] = ["control_node", "consciousness_node", "regulatory_node"]
|
||||
config_args: Dict[str, str] = {
|
||||
"api_key": "",
|
||||
"max_results": "5",
|
||||
"search_depth": "basic",
|
||||
"include_answer": "true",
|
||||
}
|
||||
category: str = "search"
|
||||
|
||||
|
||||
async def _resolve_api_key(explicit: Optional[str]) -> Optional[str]:
|
||||
"""按优先级解析 Tavily API key:显式参数 > GSM 配置 > 环境变量。"""
|
||||
if explicit:
|
||||
return explicit
|
||||
try:
|
||||
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
|
||||
|
||||
# 工具调用是高频热路径,走 Object Store 快照而不是 actor RPC
|
||||
snapshot = await fetch_snapshot()
|
||||
cfg = snapshot.tool_configs.get("tavily_search") or {}
|
||||
if isinstance(cfg, dict) and cfg.get("api_key"):
|
||||
return cfg["api_key"]
|
||||
except Exception:
|
||||
pass
|
||||
return os.environ.get("TAVILY_API_KEY")
|
||||
|
||||
|
||||
async def tavily_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
search_depth: str = "basic",
|
||||
include_answer: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
) -> str:
|
||||
"""使用 Tavily 进行网络搜索,获取高质量的网络搜索结果。
|
||||
|
||||
Args:
|
||||
query: 搜索查询内容
|
||||
max_results: 返回的最大结果数量(1-10)
|
||||
search_depth: 搜索深度,"basic" 或 "advanced"
|
||||
include_answer: 是否包含 AI 生成的答案摘要
|
||||
api_key: 可选;不传则按 GSM 配置 → 环境变量顺序解析
|
||||
|
||||
Returns:
|
||||
格式化的搜索结果文本,包含标题、URL、摘要和可选的 AI 答案
|
||||
"""
|
||||
resolved_key = await _resolve_api_key(api_key)
|
||||
if not resolved_key:
|
||||
return (
|
||||
"[Error] Tavily API key 未配置。"
|
||||
"请在 ``/api/v1/resource/tool/config`` 写入或设置环境变量 ``TAVILY_API_KEY``。"
|
||||
)
|
||||
|
||||
try:
|
||||
client = AsyncTavilyClient(api_key=resolved_key)
|
||||
result = await client.search(
|
||||
query=query,
|
||||
max_results=min(max_results, 10),
|
||||
search_depth=search_depth,
|
||||
include_answer=include_answer,
|
||||
)
|
||||
|
||||
lines = []
|
||||
if include_answer and result.get("answer"):
|
||||
lines.append(f"【AI 摘要】{result['answer']}\n")
|
||||
|
||||
results = result.get("results", [])
|
||||
if not results:
|
||||
return "No results found for the query."
|
||||
|
||||
lines.append("【搜索结果】")
|
||||
for i, item in enumerate(results, 1):
|
||||
title = item.get("title", "Untitled")
|
||||
url = item.get("url", "")
|
||||
content = item.get("content", "").strip()
|
||||
lines.append(f"\n{i}. {title}")
|
||||
lines.append(f" URL: {url}")
|
||||
if content:
|
||||
lines.append(f" {content[:300]}{'...' if len(content) > 300 else ''}")
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"[Error] Tavily search failed: {str(e)}"
|
||||
@@ -1,49 +0,0 @@
|
||||
import os
|
||||
from typing import List, Literal, Dict
|
||||
|
||||
from kilostar.plugin.tool_plugin.base_tool import BaseToolData
|
||||
|
||||
|
||||
class WriteFileToolData(BaseToolData):
|
||||
is_system: bool = True
|
||||
action_scope: List[
|
||||
Literal[
|
||||
"control_node",
|
||||
"consciousness_node",
|
||||
"regulatory_node",
|
||||
"growth_node",
|
||||
"",
|
||||
]
|
||||
] = []
|
||||
config_args: Dict[str, str] = {}
|
||||
category: str = "system"
|
||||
|
||||
|
||||
async def write_file(file_path: str, content: str) -> str:
|
||||
"""将内容写入指定文件(会覆盖已有内容,自动创建目录)。
|
||||
|
||||
Args:
|
||||
file_path: 文件的路径
|
||||
content: 要写入的内容
|
||||
|
||||
Returns:
|
||||
操作结果描述
|
||||
"""
|
||||
from kilostar.utils.sandbox import validate_path, PathViolation
|
||||
|
||||
try:
|
||||
file_path = validate_path(file_path, write=True)
|
||||
except PathViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
try:
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if dir_path:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return f"已成功写入文件: {file_path}({len(content)} 字符)"
|
||||
except Exception as e:
|
||||
return f"[Error] 写入文件失败: {e}"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""KiloStar 重型插件(Organization)运行时。
|
||||
|
||||
每个重型插件 = 一个组织/部门:
|
||||
- ``data/plugin/<org_name>/`` 目录约定
|
||||
- 内部多个平级专家 agent,通过 ``consult`` 工具互通
|
||||
- 单机模式下是普通对象,分布式模式下是 ray actor
|
||||
- 对外两条边:cabinet tool(阻塞)+ 用户 API(射后不管)
|
||||
"""
|
||||
|
||||
from kilostar.plugin_runtime.event import OrgEvent, OrgEventType, TaskState
|
||||
from kilostar.plugin_runtime.manifest import OrgManifest, OrgDependencies
|
||||
from kilostar.plugin_runtime.agents_config import (
|
||||
AgentsConfig,
|
||||
AgentDef,
|
||||
AgentModelRef,
|
||||
OrchestrationConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OrgEvent",
|
||||
"OrgEventType",
|
||||
"TaskState",
|
||||
"OrgManifest",
|
||||
"OrgDependencies",
|
||||
"AgentsConfig",
|
||||
"AgentDef",
|
||||
"AgentModelRef",
|
||||
"OrchestrationConfig",
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""agents.json 的 pydantic 模型。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AgentModelRef(BaseModel):
|
||||
"""agent 用哪个 provider + 哪个 model。"""
|
||||
|
||||
provider_title: str
|
||||
model_id: str
|
||||
|
||||
|
||||
class AgentDef(BaseModel):
|
||||
"""单个专家 agent 定义。
|
||||
|
||||
``peers`` 列出本 agent 能 ``consult`` 的同事;为空则不能向同事发起咨询。
|
||||
``tools`` / ``skills`` 名字按下面顺序解析:
|
||||
1. 本组织 toolset/ 里声明的工具
|
||||
2. cabinet 全局工具白名单(python_executor 等基础工具)
|
||||
"""
|
||||
|
||||
name: str
|
||||
role: str = ""
|
||||
system_prompt: str = ""
|
||||
model: AgentModelRef
|
||||
tools: List[str] = Field(default_factory=list)
|
||||
skills: List[str] = Field(default_factory=list)
|
||||
peers: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OrchestrationConfig(BaseModel):
|
||||
"""编排策略:第一版只有 react;entry 决定任务进来交给谁。"""
|
||||
|
||||
type: Literal["react"] = "react"
|
||||
entry: str
|
||||
|
||||
|
||||
class AgentsConfig(BaseModel):
|
||||
agents: List[AgentDef]
|
||||
orchestration: OrchestrationConfig
|
||||
|
||||
def get(self, name: str) -> Optional[AgentDef]:
|
||||
for a in self.agents:
|
||||
if a.name == name:
|
||||
return a
|
||||
return None
|
||||
@@ -0,0 +1,442 @@
|
||||
"""BaseOrganization:重型插件基类。
|
||||
|
||||
设计要点:
|
||||
- 单机模式 = 普通 Python 对象,分布式 = ray actor(``@actor_class`` 装饰子类)
|
||||
- 内置 ``asyncio.Queue`` 输入队列 + 任务表
|
||||
- 对外两条通道:``dispatch`` (阻塞) / ``submit`` (射后不管),底层都汇集到 ``_run_task``
|
||||
- 子类只需覆写 ``setup`` / ``react`` 两个钩子;零代码插件由 ``agents.json`` 声明驱动
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
|
||||
from ulid import ULID
|
||||
|
||||
from kilostar.plugin_runtime.event import OrgEvent, TaskState
|
||||
from kilostar.plugin_runtime.manifest import OrgManifest
|
||||
from kilostar.plugin_runtime.agents_config import AgentsConfig, AgentDef
|
||||
from kilostar.utils.logger import get_logger
|
||||
from kilostar.utils.settings import get_artifact_dir
|
||||
|
||||
|
||||
class BaseOrganization:
|
||||
"""重型插件基类。
|
||||
|
||||
生命周期:
|
||||
``__init__(manifest, agents_config, plugin_dir)`` → ``setup()`` → 持续运行 →
|
||||
``shutdown()``
|
||||
|
||||
setup 期间会:加载本组织 toolset/、构造 agent 实例(带 consult 工具)、
|
||||
起后台 worker 协程消费输入队列。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_dict: Dict[str, Any],
|
||||
agents_dict: Dict[str, Any],
|
||||
plugin_dir: str,
|
||||
) -> None:
|
||||
self.manifest = OrgManifest.model_validate(manifest_dict)
|
||||
self.agents_config = AgentsConfig.model_validate(agents_dict)
|
||||
self.plugin_dir = plugin_dir
|
||||
self.name = self.manifest.name
|
||||
self.logger = get_logger(f"org.{self.name}")
|
||||
|
||||
# 任务队列与状态表
|
||||
self._queue: asyncio.Queue = asyncio.Queue()
|
||||
self._tasks: Dict[str, TaskState] = {}
|
||||
self._futures: Dict[str, asyncio.Future] = {}
|
||||
self._streams: Dict[str, asyncio.Queue] = {}
|
||||
|
||||
# 后台消费协程
|
||||
self._worker_task: Optional[asyncio.Task] = None
|
||||
self._stopped = False
|
||||
|
||||
# 由 setup 填充
|
||||
self._tools_by_name: Dict[str, Callable] = {}
|
||||
self._agents: Dict[str, Any] = {} # name -> pydantic-ai Agent
|
||||
|
||||
# ─── 生命周期 ──────────────────────────────────────────────
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""加载本组织资源,实例化 agents,启动队列消费协程。
|
||||
|
||||
子类可以 override 来扩展(连数据库、起子进程等),但应该 ``await super().setup()``。
|
||||
"""
|
||||
await self._load_local_tools()
|
||||
await self._build_agents()
|
||||
self._worker_task = asyncio.create_task(self._consume_queue())
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self._stopped = True
|
||||
if self._worker_task is not None:
|
||||
self._worker_task.cancel()
|
||||
|
||||
# ─── 对外通道 ──────────────────────────────────────────────
|
||||
|
||||
async def dispatch(
|
||||
self, task_description: str, ctx: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""cabinet 同步入口:阻塞等到任务完成才返回。
|
||||
|
||||
Returns:
|
||||
``{"task_id": ..., "status": ..., "result": ..., "error": ...}``
|
||||
"""
|
||||
task_id = await self._enqueue(task_description, ctx or {}, source="cabinet")
|
||||
future = self._futures[task_id]
|
||||
try:
|
||||
return await future
|
||||
finally:
|
||||
self._futures.pop(task_id, None)
|
||||
|
||||
async def submit(
|
||||
self, task_description: str, ctx: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""用户 API 入口:投入队列就返回,状态走 ``status`` / ``stream``。"""
|
||||
return await self._enqueue(task_description, ctx or {}, source="user")
|
||||
|
||||
async def status(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
ts = self._tasks.get(task_id)
|
||||
if ts is None:
|
||||
return None
|
||||
return {
|
||||
"task_id": ts.task_id,
|
||||
"status": ts.status,
|
||||
"description": ts.description,
|
||||
"source": ts.source,
|
||||
"result": ts.result,
|
||||
"error": ts.error,
|
||||
"events": [e.to_dict() for e in ts.events],
|
||||
}
|
||||
|
||||
async def stream(self, task_id: str) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""SSE 端点用:异步生成器,每 yield 一个事件 dict。
|
||||
|
||||
如果 task 已经完成,把历史事件回放完毕后即结束;否则持续推送实时事件。
|
||||
"""
|
||||
ts = self._tasks.get(task_id)
|
||||
if ts is None:
|
||||
return
|
||||
|
||||
# 历史回放
|
||||
for ev in list(ts.events):
|
||||
yield ev.to_dict()
|
||||
|
||||
if ts.status in ("completed", "failed"):
|
||||
return
|
||||
|
||||
# 实时订阅:用一个 per-stream queue
|
||||
sub_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._streams.setdefault(task_id, sub_queue)
|
||||
try:
|
||||
while True:
|
||||
ev = await sub_queue.get()
|
||||
if ev is None:
|
||||
break
|
||||
yield ev.to_dict()
|
||||
finally:
|
||||
self._streams.pop(task_id, None)
|
||||
|
||||
async def list_tasks(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"task_id": ts.task_id,
|
||||
"status": ts.status,
|
||||
"source": ts.source,
|
||||
"description": ts.description,
|
||||
}
|
||||
for ts in self._tasks.values()
|
||||
]
|
||||
|
||||
# ─── 子类钩子 ──────────────────────────────────────────────
|
||||
|
||||
async def react(
|
||||
self,
|
||||
task_description: str,
|
||||
ctx: Dict[str, Any],
|
||||
emit: Callable[[OrgEvent], Any],
|
||||
) -> Any:
|
||||
"""默认 ReAct 实现:把任务交给 entry agent 跑一轮。
|
||||
|
||||
子类可覆盖以实现自定义编排(DAG/pipeline)。
|
||||
"""
|
||||
entry_name = self.agents_config.orchestration.entry
|
||||
entry_agent = self._agents.get(entry_name)
|
||||
if entry_agent is None:
|
||||
raise RuntimeError(f"entry agent {entry_name!r} not found in {self.name}")
|
||||
|
||||
await emit(
|
||||
OrgEvent(
|
||||
task_id=ctx["task_id"],
|
||||
type="step",
|
||||
payload={"agent": entry_name, "phase": "start"},
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await entry_agent.run(user_prompt=task_description)
|
||||
output = getattr(result, "output", None) or str(result)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"entry agent {entry_name} run failed: {e}")
|
||||
raise
|
||||
await emit(
|
||||
OrgEvent(
|
||||
task_id=ctx["task_id"],
|
||||
type="step",
|
||||
payload={"agent": entry_name, "phase": "end"},
|
||||
)
|
||||
)
|
||||
return output
|
||||
|
||||
# ─── 内部实现 ──────────────────────────────────────────────
|
||||
|
||||
async def _enqueue(
|
||||
self,
|
||||
task_description: str,
|
||||
ctx: Dict[str, Any],
|
||||
source: str,
|
||||
) -> str:
|
||||
task_id = str(ULID())
|
||||
trace_id = ctx.get("trace_id") or task_id
|
||||
user_id = ctx.get("user_id", "")
|
||||
|
||||
# 沙箱目录:data/artifact/<trace>/<org>/
|
||||
artifact_dir = str(get_artifact_dir() / trace_id / self.name)
|
||||
|
||||
ts = TaskState(
|
||||
task_id=task_id,
|
||||
org_name=self.name,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
description=task_description,
|
||||
source=source, # type: ignore[arg-type]
|
||||
)
|
||||
self._tasks[task_id] = ts
|
||||
self._futures[task_id] = asyncio.get_event_loop().create_future()
|
||||
|
||||
full_ctx = {
|
||||
**ctx,
|
||||
"trace_id": trace_id,
|
||||
"user_id": user_id,
|
||||
"task_id": task_id,
|
||||
"source": source,
|
||||
"artifact_dir": artifact_dir,
|
||||
}
|
||||
await self._queue.put((task_id, task_description, full_ctx))
|
||||
|
||||
# 持久化(best-effort,PG 不可用时静默)
|
||||
await self._persist_task(ts)
|
||||
return task_id
|
||||
|
||||
async def _consume_queue(self) -> None:
|
||||
while not self._stopped:
|
||||
try:
|
||||
task_id, desc, ctx = await self._queue.get()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
try:
|
||||
await self._run_task(task_id, desc, ctx)
|
||||
except Exception as e:
|
||||
self.logger.exception(f"task {task_id} crashed: {e}")
|
||||
|
||||
async def _run_task(self, task_id: str, desc: str, ctx: Dict[str, Any]) -> None:
|
||||
ts = self._tasks[task_id]
|
||||
ts.status = "running"
|
||||
await self._persist_task(ts)
|
||||
|
||||
async def _emit(ev: OrgEvent) -> None:
|
||||
ts.events.append(ev)
|
||||
sub = self._streams.get(task_id)
|
||||
if sub is not None:
|
||||
await sub.put(ev)
|
||||
await self._persist_event(ts, ev)
|
||||
|
||||
try:
|
||||
result = await self.react(desc, ctx, _emit)
|
||||
ts.status = "completed"
|
||||
ts.result = result
|
||||
await _emit(
|
||||
OrgEvent(task_id=task_id, type="done", payload={"result": result})
|
||||
)
|
||||
except Exception as e:
|
||||
ts.status = "failed"
|
||||
ts.error = str(e)
|
||||
await _emit(
|
||||
OrgEvent(task_id=task_id, type="error", payload={"error": str(e)})
|
||||
)
|
||||
finally:
|
||||
await self._persist_task(ts)
|
||||
# 通知 stream 关闭
|
||||
sub = self._streams.get(task_id)
|
||||
if sub is not None:
|
||||
await sub.put(None)
|
||||
# 唤醒 dispatch 端
|
||||
fut = self._futures.get(task_id)
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"status": ts.status,
|
||||
"result": ts.result,
|
||||
"error": ts.error,
|
||||
}
|
||||
)
|
||||
|
||||
# ─── PG 持久化 ─────────────────────────────────────────────
|
||||
|
||||
async def _persist_task(self, ts: TaskState) -> None:
|
||||
"""把任务状态写到 PG。失败不阻塞执行。"""
|
||||
try:
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
pg = ray_actor_hook("postgres_database").postgres_database
|
||||
await pg.upsert_org_task.remote(
|
||||
task_id=ts.task_id,
|
||||
org_name=ts.org_name,
|
||||
trace_id=ts.trace_id,
|
||||
user_id=ts.user_id,
|
||||
status=ts.status,
|
||||
description=ts.description,
|
||||
source=ts.source,
|
||||
result=ts.result if isinstance(ts.result, (str, dict, list, type(None))) else str(ts.result),
|
||||
error=ts.error,
|
||||
)
|
||||
except Exception:
|
||||
self.logger.debug("persist_task skipped (no DB / not ready)")
|
||||
|
||||
async def _persist_event(self, ts: TaskState, ev: OrgEvent) -> None:
|
||||
try:
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
pg = ray_actor_hook("postgres_database").postgres_database
|
||||
await pg.append_org_task_event.remote(
|
||||
task_id=ts.task_id, event=ev.to_dict()
|
||||
)
|
||||
except Exception:
|
||||
self.logger.debug("persist_event skipped")
|
||||
|
||||
# ─── 资源加载 ──────────────────────────────────────────────
|
||||
|
||||
async def _load_local_tools(self) -> None:
|
||||
"""加载本组织 toolset/ 目录下的工具。
|
||||
|
||||
复用 ``GlobalToolManager`` 的逻辑:扫描 manifest.json,按 name 注入函数表。
|
||||
全局工具白名单(``python_executor`` 等)也合并进来,给 agent 兜底。
|
||||
"""
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
toolset_dir = Path(self.plugin_dir) / "toolset"
|
||||
if toolset_dir.exists() and (toolset_dir / "manifest.json").exists():
|
||||
with open(toolset_dir / "manifest.json", "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
for tool_def in manifest.get("tools", []):
|
||||
tname = tool_def.get("name")
|
||||
tfile = tool_def.get("file", f"{tname}.py")
|
||||
if not tname:
|
||||
continue
|
||||
fpath = toolset_dir / tfile
|
||||
if not fpath.exists():
|
||||
self.logger.warning(f"tool file not found: {fpath}")
|
||||
continue
|
||||
module_name = f"data.plugin.{self.name}.toolset.{tname}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(fpath))
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
func = getattr(mod, tname, None)
|
||||
if callable(func):
|
||||
self._tools_by_name[tname] = func
|
||||
|
||||
# 从全局 tool manager 借通用工具
|
||||
await self._merge_global_tools()
|
||||
|
||||
async def _merge_global_tools(self) -> None:
|
||||
"""合并 cabinet 全局工具白名单(python_executor 等基础工具)。"""
|
||||
try:
|
||||
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
|
||||
|
||||
snapshot = await fetch_snapshot()
|
||||
for name, func in snapshot.all_funcs.items():
|
||||
self._tools_by_name.setdefault(name, func)
|
||||
except Exception:
|
||||
self.logger.debug("global tools not available; org runs with local only")
|
||||
|
||||
async def _build_agents(self) -> None:
|
||||
"""按 agents.json 实例化 pydantic-ai Agent。
|
||||
|
||||
每个 agent 注入:
|
||||
- 自己声明的 tools(从 ``_tools_by_name`` 取)
|
||||
- 一个特殊 ``consult`` 工具(如果 peers 非空),用于跨 agent 协作
|
||||
"""
|
||||
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
||||
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
|
||||
|
||||
snapshot = await fetch_snapshot()
|
||||
factory = AgentFactory()
|
||||
|
||||
for adef in self.agents_config.agents:
|
||||
provider = snapshot.providers.get(adef.model.provider_title)
|
||||
if provider is None:
|
||||
self.logger.warning(
|
||||
f"provider {adef.model.provider_title!r} not found; agent {adef.name} skipped"
|
||||
)
|
||||
continue
|
||||
tools = [
|
||||
self._tools_by_name[t]
|
||||
for t in adef.tools
|
||||
if t in self._tools_by_name
|
||||
]
|
||||
consult_tool = self._make_consult_tool(adef)
|
||||
if consult_tool is not None:
|
||||
tools.append(consult_tool)
|
||||
|
||||
try:
|
||||
agent = factory.create_agent(
|
||||
provider=provider,
|
||||
model_id=adef.model.model_id,
|
||||
output_type=str,
|
||||
system_prompt=adef.system_prompt or f"You are {adef.role}.",
|
||||
deps_type=type(None),
|
||||
agent_name=f"{self.name}.{adef.name}",
|
||||
tools=tools,
|
||||
toolsets=None,
|
||||
)
|
||||
self._agents[adef.name] = agent
|
||||
except Exception as e:
|
||||
self.logger.warning(f"build agent {adef.name} failed: {e}")
|
||||
|
||||
def _make_consult_tool(self, adef: AgentDef):
|
||||
"""为 agent 生成一个 ``consult(peer, question)`` 工具。
|
||||
|
||||
peers 为空则不生成;调用时直接 await 同事 agent.run。
|
||||
"""
|
||||
if not adef.peers:
|
||||
return None
|
||||
peers = list(adef.peers)
|
||||
org = self
|
||||
|
||||
async def consult(peer: str, question: str) -> str:
|
||||
"""向同事 agent 提问以获取专业意见。
|
||||
|
||||
Args:
|
||||
peer: 同事 agent 名字
|
||||
question: 要问的问题
|
||||
"""
|
||||
if peer not in peers:
|
||||
return f"[error] {peer} 不在你的协作列表中: {peers}"
|
||||
target = org._agents.get(peer)
|
||||
if target is None:
|
||||
return f"[error] 同事 agent {peer} 未启动"
|
||||
try:
|
||||
resp = await target.run(user_prompt=question)
|
||||
return getattr(resp, "output", None) or str(resp)
|
||||
except Exception as e:
|
||||
return f"[error] {peer} 失败: {e}"
|
||||
|
||||
return consult
|
||||
@@ -0,0 +1,63 @@
|
||||
"""组织事件协议:组织 → 前端/PG 的统一推送格式。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Literal
|
||||
|
||||
OrgEventType = Literal[
|
||||
"log", # 普通文本日志
|
||||
"step", # 阶段推进(agent 切换、工具调用前后)
|
||||
"artifact", # 生成了产物(沿用 send_file 的 url 协议)
|
||||
"approval_request", # 需要用户审批
|
||||
"done", # 任务完成
|
||||
"error", # 任务失败
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgEvent:
|
||||
"""组织事件:一个 task 的执行过程会产生一连串这种事件。
|
||||
|
||||
被 SSE 推给前端面板,被 DAO 追加到 ``org_task.events`` JSONB 字段。
|
||||
序列化用 ``to_dict``,反序列化用 ``from_dict``。
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
type: OrgEventType
|
||||
payload: Dict[str, Any] = field(default_factory=dict)
|
||||
ts: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"type": self.type,
|
||||
"payload": self.payload,
|
||||
"ts": self.ts,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "OrgEvent":
|
||||
return cls(
|
||||
task_id=data["task_id"],
|
||||
type=data["type"],
|
||||
payload=data.get("payload", {}),
|
||||
ts=data.get("ts", time.time()),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskState:
|
||||
"""组织内存中的任务状态(重启不保留,但事件流通过 PG 持久化)。"""
|
||||
|
||||
task_id: str
|
||||
org_name: str
|
||||
trace_id: str
|
||||
user_id: str
|
||||
status: Literal["pending", "running", "completed", "failed"] = "pending"
|
||||
description: str = ""
|
||||
source: Literal["cabinet", "user"] = "user"
|
||||
events: List[OrgEvent] = field(default_factory=list)
|
||||
result: Any = None
|
||||
error: str | None = None
|
||||
@@ -0,0 +1,128 @@
|
||||
"""目录扫描 + 装载流水线。
|
||||
|
||||
公开 ``discover_plugins(dir)`` 和 ``load_plugin(plugin_dir)`` 两个函数:
|
||||
- discover:列出所有插件名(manifest 校验通过的)
|
||||
- load:读 manifest + agents.json + 解析 entry class,返回可实例化的 ``(class, manifest, agents_dict, plugin_dir)``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Type
|
||||
|
||||
from kilostar.plugin_runtime.manifest import OrgManifest
|
||||
from kilostar.plugin_runtime.agents_config import AgentsConfig
|
||||
from kilostar.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("plugin_loader")
|
||||
|
||||
|
||||
def discover_plugins(plugin_root: Path) -> List[Path]:
|
||||
"""扫描 plugin 根目录,返回所有合法插件目录。
|
||||
|
||||
合法 = 含 ``manifest.json`` 且能通过 pydantic 校验。
|
||||
跳过 ``skill/`` 子目录(那是技能仓库,不是组织)。
|
||||
"""
|
||||
if not plugin_root.exists() or not plugin_root.is_dir():
|
||||
return []
|
||||
results: List[Path] = []
|
||||
for entry in plugin_root.iterdir():
|
||||
if not entry.is_dir() or entry.name.startswith("__"):
|
||||
continue
|
||||
if entry.name in ("skill",):
|
||||
continue
|
||||
manifest_path = entry / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
OrgManifest.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"skip plugin {entry.name}: invalid manifest ({e})")
|
||||
continue
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
def load_plugin(
|
||||
plugin_dir: Path,
|
||||
) -> Tuple[Type[Any], Dict[str, Any], Dict[str, Any], str]:
|
||||
"""加载单个插件,返回 (Class, manifest_dict, agents_dict, plugin_dir_str)。
|
||||
|
||||
- 解析 manifest.json + agents.json
|
||||
- 如果 manifest.entry 为空,使用 ``BaseOrganization`` 默认实现
|
||||
- 否则按 ``"core.organization:DataCleaningOrg"`` 形式动态 import 子类
|
||||
"""
|
||||
with open(plugin_dir / "manifest.json", "r", encoding="utf-8") as f:
|
||||
manifest_dict = json.load(f)
|
||||
manifest = OrgManifest.model_validate(manifest_dict)
|
||||
|
||||
agents_path = plugin_dir / "agents.json"
|
||||
if not agents_path.exists():
|
||||
raise FileNotFoundError(f"plugin {manifest.name} missing agents.json")
|
||||
with open(agents_path, "r", encoding="utf-8") as f:
|
||||
agents_dict = json.load(f)
|
||||
AgentsConfig.model_validate(agents_dict)
|
||||
|
||||
if manifest.entry:
|
||||
cls = _import_entry_class(plugin_dir, manifest.entry, manifest.name)
|
||||
else:
|
||||
from kilostar.plugin_runtime.base_organization import BaseOrganization
|
||||
|
||||
cls = BaseOrganization
|
||||
|
||||
return cls, manifest_dict, agents_dict, str(plugin_dir)
|
||||
|
||||
|
||||
def _import_entry_class(plugin_dir: Path, entry: str, plugin_name: str) -> Type[Any]:
|
||||
"""形如 ``core.organization:DataCleaningOrg`` 的入口字符串解析。
|
||||
|
||||
``:`` 左边是相对插件根的模块路径(用 / 或 . 分隔均可),右边是类名。
|
||||
"""
|
||||
if ":" not in entry:
|
||||
raise ValueError(f"invalid entry {entry!r}: missing ':<ClassName>'")
|
||||
mod_path, class_name = entry.split(":", 1)
|
||||
rel = mod_path.replace(".", "/").lstrip("/")
|
||||
file_path = plugin_dir / f"{rel}.py"
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"plugin {plugin_name} entry file not found: {file_path}")
|
||||
|
||||
module_name = f"data.plugin.{plugin_name}.{mod_path.replace('/', '.')}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load module {module_name}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
cls = getattr(mod, class_name, None)
|
||||
if cls is None:
|
||||
raise AttributeError(f"plugin {plugin_name}: {class_name} not found in {file_path}")
|
||||
return cls
|
||||
|
||||
|
||||
async def install_dependencies(deps_python: List[str]) -> None:
|
||||
"""用 uv 安装组织声明的 python 依赖。
|
||||
|
||||
第一版直接装到主 venv,简单粗暴;viceroy 接管后这步会被替换。
|
||||
"""
|
||||
if not deps_python:
|
||||
return
|
||||
import asyncio as _asyncio
|
||||
|
||||
cmd = ["uv", "pip", "install", *deps_python]
|
||||
proc = await _asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=_asyncio.subprocess.PIPE,
|
||||
stderr=_asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"uv pip install failed (rc={proc.returncode}): {stderr.decode()}"
|
||||
)
|
||||
logger.info(f"installed deps: {deps_python}")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""manifest.json 的 pydantic 模型。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OrgDependencies(BaseModel):
|
||||
"""组织依赖声明。
|
||||
|
||||
``python`` 列表会在 install 阶段交给 uv 处理;``plugins`` 留给后续做插件间依赖。
|
||||
"""
|
||||
|
||||
python: List[str] = Field(default_factory=list)
|
||||
plugins: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OrgUIRef(BaseModel):
|
||||
"""前端 dashboard 入口(先占位,Tauri 化后接通)。"""
|
||||
|
||||
entry: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class OrgManifest(BaseModel):
|
||||
"""重型插件的章程文件。
|
||||
|
||||
name 是目录名也是 actor 注册名前缀(实际 actor name = ``org_<name>``)。
|
||||
entry 留空则使用 ``BaseOrganization`` 默认实现,纯声明式插件即可跑起来;
|
||||
填写时形如 ``core.organization:DataCleaningOrg`` 指向子类。
|
||||
"""
|
||||
|
||||
name: str
|
||||
version: str = "0.1.0"
|
||||
display_name: str = ""
|
||||
description: str = ""
|
||||
|
||||
# 入口与并发
|
||||
entry: Optional[str] = None
|
||||
concurrency: Literal["queue", "parallel"] = "queue"
|
||||
node_affinity: Literal["cpu", "core", "gpu"] = "cpu"
|
||||
|
||||
# 对外
|
||||
api_prefix: Optional[str] = None
|
||||
capabilities: List[str] = Field(default_factory=list)
|
||||
|
||||
# 资源
|
||||
dependencies: OrgDependencies = Field(default_factory=OrgDependencies)
|
||||
|
||||
# UI
|
||||
ui: OrgUIRef = Field(default_factory=OrgUIRef)
|
||||
|
||||
@property
|
||||
def actor_name(self) -> str:
|
||||
return f"org_{self.name}"
|
||||
@@ -0,0 +1,137 @@
|
||||
"""GlobalPluginManager:重型插件统一管理 actor。
|
||||
|
||||
职责:
|
||||
- 启动期扫描 ``data/plugin/`` 下所有组织,依次 setup
|
||||
- 运行期提供 install / uninstall / reload 三个热装接口
|
||||
- 把每个组织注册为 cabinet tool + 挂 FastAPI router
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kilostar.plugin_runtime.loader import (
|
||||
discover_plugins,
|
||||
install_dependencies,
|
||||
load_plugin,
|
||||
)
|
||||
from kilostar.plugin_runtime.manifest import OrgManifest
|
||||
from kilostar.plugin_runtime.tool_bridge import make_dispatch_tool
|
||||
from kilostar.utils.logger import get_logger
|
||||
from kilostar.utils.ray_compat import _STANDALONE, actor_class
|
||||
from kilostar.utils.ray_hook import register_standalone
|
||||
from kilostar.utils.settings import get_plugin_dir
|
||||
|
||||
logger = get_logger("plugin_manager")
|
||||
|
||||
|
||||
@actor_class
|
||||
class GlobalPluginManager:
|
||||
"""单机模式下是对象,分布式下是 ray actor。
|
||||
|
||||
每个 loaded 组织保存其 manifest 和 actor handle(standalone=proxy,dist=ray handle)。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._orgs: Dict[str, Dict[str, Any]] = {}
|
||||
self._dispatch_tools: Dict[str, Any] = {}
|
||||
|
||||
async def bootstrap(self) -> None:
|
||||
"""启动期一次性扫描并加载所有插件。"""
|
||||
plugin_root = get_plugin_dir()
|
||||
plugin_dirs = discover_plugins(plugin_root)
|
||||
for plugin_dir in plugin_dirs:
|
||||
try:
|
||||
await self._install_from_path(plugin_dir)
|
||||
except Exception as e:
|
||||
logger.error(f"bootstrap: failed to load plugin {plugin_dir.name}: {e}")
|
||||
|
||||
# ─── 热装载接口 ─────────────────────────────────────────────
|
||||
|
||||
async def install(self, name: str) -> Dict[str, Any]:
|
||||
"""热装载一个插件(按目录名)。"""
|
||||
plugin_dir = get_plugin_dir() / name
|
||||
if not plugin_dir.exists():
|
||||
raise FileNotFoundError(f"plugin dir not found: {plugin_dir}")
|
||||
if name in self._orgs:
|
||||
await self.uninstall(name)
|
||||
await self._install_from_path(plugin_dir)
|
||||
return {"name": name, "status": "installed"}
|
||||
|
||||
async def uninstall(self, name: str) -> Dict[str, Any]:
|
||||
"""卸载一个插件。"""
|
||||
org_info = self._orgs.pop(name, None)
|
||||
if org_info is None:
|
||||
return {"name": name, "status": "not_found"}
|
||||
# shutdown actor
|
||||
try:
|
||||
handle = org_info.get("handle")
|
||||
if handle is not None:
|
||||
await handle.shutdown.remote()
|
||||
except Exception as e:
|
||||
logger.warning(f"shutdown org_{name} failed: {e}")
|
||||
# 移除 dispatch tool
|
||||
self._dispatch_tools.pop(f"dispatch_to_{name}", None)
|
||||
logger.info(f"uninstalled plugin: {name}")
|
||||
return {"name": name, "status": "uninstalled"}
|
||||
|
||||
async def reload(self, name: str) -> Dict[str, Any]:
|
||||
"""热重载(卸载 + 安装)。"""
|
||||
await self.uninstall(name)
|
||||
return await self.install(name)
|
||||
|
||||
# ─── 查询接口 ──────────────────────────────────────────────
|
||||
|
||||
def list_plugins(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"display_name": info.get("display_name", name),
|
||||
"description": info.get("description", ""),
|
||||
"status": "running",
|
||||
}
|
||||
for name, info in self._orgs.items()
|
||||
]
|
||||
|
||||
def get_dispatch_tools(self) -> Dict[str, Any]:
|
||||
"""返回所有 dispatch tools 的 {tool_name: callable} 字典。"""
|
||||
return dict(self._dispatch_tools)
|
||||
|
||||
# ─── 内部 ──────────────────────────────────────────────────
|
||||
|
||||
async def _install_from_path(self, plugin_dir: Path) -> None:
|
||||
cls, manifest_dict, agents_dict, dir_str = load_plugin(plugin_dir)
|
||||
manifest = OrgManifest.model_validate(manifest_dict)
|
||||
name = manifest.name
|
||||
|
||||
# 装依赖
|
||||
if manifest.dependencies.python:
|
||||
await install_dependencies(manifest.dependencies.python)
|
||||
|
||||
# 实例化 organization actor
|
||||
instance = cls(manifest_dict, agents_dict, dir_str)
|
||||
await instance.setup()
|
||||
|
||||
# 注册到 ray_actor_hook 命名空间
|
||||
actor_name = manifest.actor_name
|
||||
if _STANDALONE:
|
||||
register_standalone(actor_name, instance)
|
||||
else:
|
||||
# 分布式模式下,这里需要把 instance 包装成 ray actor
|
||||
# 第一版走 standalone 逻辑(两种模式统一 register 到本进程)
|
||||
# 真正分布式隔离等后续做
|
||||
register_standalone(actor_name, instance)
|
||||
|
||||
# 生成 dispatch tool
|
||||
tool = make_dispatch_tool(name, manifest.display_name, manifest.description)
|
||||
self._dispatch_tools[f"dispatch_to_{name}"] = tool
|
||||
|
||||
self._orgs[name] = {
|
||||
"display_name": manifest.display_name,
|
||||
"description": manifest.description,
|
||||
"manifest": manifest_dict,
|
||||
"handle": instance,
|
||||
"actor_name": actor_name,
|
||||
}
|
||||
logger.info(f"loaded plugin: {name} (actor={actor_name})")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""把组织包装成 cabinet 可调用的高阶 tool。
|
||||
|
||||
每个组织 → 一个 ``dispatch_to_<org>(task_description)`` 工具。
|
||||
ConsciousnessNode/ControlNode 通过这个工具向部门派单,等待部门完成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict
|
||||
|
||||
|
||||
def make_dispatch_tool(org_name: str, display_name: str, description: str) -> Callable:
|
||||
"""生成对应组织的 dispatch tool。
|
||||
|
||||
工具签名故意保持简单:只收一个自然语言任务描述,cabinet 不需要懂部门内部
|
||||
capability 划分;部门内部 ReAct 自己决定怎么干。
|
||||
"""
|
||||
tool_name = f"dispatch_to_{org_name}"
|
||||
desc_text = description or f"把任务派给{display_name or org_name}部门,由部门内部多 agent 协作完成。"
|
||||
|
||||
async def _impl(task_description: str) -> str:
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
actor_name = f"org_{org_name}"
|
||||
actor = ray_actor_hook(actor_name)
|
||||
target = getattr(actor, actor_name)
|
||||
result = await target.dispatch.remote(task_description, {})
|
||||
if result.get("status") == "completed":
|
||||
return str(result.get("result") or "")
|
||||
return f"[{org_name} 任务失败] {result.get('error') or 'unknown'}"
|
||||
|
||||
_impl.__name__ = tool_name
|
||||
_impl.__doc__ = (
|
||||
f"{desc_text}\n\n"
|
||||
"Args:\n"
|
||||
" task_description: 用自然语言描述要部门完成的任务。\n\n"
|
||||
"Returns:\n"
|
||||
" 部门交付的结果文本,失败时返回错误说明。\n"
|
||||
)
|
||||
return _impl
|
||||
|
||||
|
||||
def collect_dispatch_tools(org_specs: Dict[str, Dict[str, str]]) -> Dict[str, Callable]:
|
||||
"""根据 ``{org_name: {"display_name": ..., "description": ...}}`` 批量生成。"""
|
||||
return {
|
||||
f"dispatch_to_{name}": make_dispatch_tool(
|
||||
name,
|
||||
spec.get("display_name", ""),
|
||||
spec.get("description", ""),
|
||||
)
|
||||
for name, spec in org_specs.items()
|
||||
}
|
||||
@@ -16,14 +16,15 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Annotated, Optional
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kilostar.core.postgres_database.model import UserAuthority
|
||||
from kilostar.core.postgres_database.model.user import User
|
||||
|
||||
|
||||
@@ -174,3 +175,61 @@ class Accessor:
|
||||
if not (has_alpha and has_digit):
|
||||
raise ValueError("密码必须同时包含字母和数字")
|
||||
return password_hasher.hash(password)
|
||||
|
||||
|
||||
# ─── Role Check ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _user_not_found_detail(request: Request | None = None) -> str:
|
||||
from kilostar.utils.i18n import t
|
||||
|
||||
loc = request.headers.get("accept-language") if request else None
|
||||
return t("user_not_found", accept_language=loc)
|
||||
|
||||
|
||||
async def get_authority(user_id: str) -> "UserAuthority":
|
||||
"""通过 PostgresDatabase Actor 查出指定用户的 ``UserAuthority``;用户不存在时抛 401。"""
|
||||
from kilostar.utils.error import UserNotExistError
|
||||
from kilostar.utils.i18n import t
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
try:
|
||||
user_authority = await postgres_database.get_user_authority.remote(
|
||||
user_id=user_id
|
||||
)
|
||||
return user_authority
|
||||
except UserNotExistError:
|
||||
raise HTTPException(status_code=401, detail=t("user_not_found"))
|
||||
except Exception as e:
|
||||
if "UserNotExistError" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=401, detail=t("user_not_found")
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
class RoleChecker:
|
||||
"""FastAPI 依赖:在路由级别按 ``UserAuthority`` 做最低权限校验。
|
||||
|
||||
例:``Depends(RoleChecker(allowed_roles=UserAuthority.ADMINISTRATOR))``。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.allowed_roles = kwargs.get(
|
||||
"allowed_roles",
|
||||
)
|
||||
|
||||
async def __call__(
|
||||
self, token_data: Annotated[TokenData, Depends(Accessor.get_current_user)]
|
||||
):
|
||||
"""对当前请求执行权限比较,权限不足抛 403,否则把 ``TokenData`` 透传给路由。"""
|
||||
user_authority = await get_authority(token_data.user_id)
|
||||
if user_authority < self.allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"message": f"User {token_data.user_id} does not have allowed roles"
|
||||
},
|
||||
)
|
||||
return token_data
|
||||
|
||||
@@ -14,14 +14,13 @@
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
import yaml
|
||||
|
||||
from kilostar.utils.config_loader import get_app_config
|
||||
|
||||
|
||||
def print_banner() -> None:
|
||||
"""在启动阶段输出 KiloStar 的 ASCII 横幅与版本/作者元信息。"""
|
||||
with open("config/config.yml", "r") as config:
|
||||
config = yaml.load(config, Loader=yaml.FullLoader)
|
||||
version = config.get("version", "unknown")
|
||||
version = get_app_config().app.version
|
||||
kilostar_banner = r"""
|
||||
██╗ ██╗██╗██╗ ██████╗ ███████╗████████╗ █████╗ ██████╗
|
||||
██║ ██╔╝██║██║ ██╔═══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.core.postgres_database.model import UserAuthority
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from kilostar.utils.i18n import t
|
||||
|
||||
|
||||
def _user_not_found_detail(request: Request | None = None) -> str:
|
||||
loc = request.headers.get("accept-language") if request else None
|
||||
return t("user_not_found", accept_language=loc)
|
||||
|
||||
|
||||
async def get_authority(user_id: str) -> UserAuthority:
|
||||
"""通过 PostgresDatabase Actor 查出指定用户的 ``UserAuthority``;用户不存在时抛 401。"""
|
||||
from kilostar.utils.error import UserNotExistError
|
||||
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
try:
|
||||
user_authority = await postgres_database.get_user_authority.remote(
|
||||
user_id=user_id
|
||||
)
|
||||
return user_authority
|
||||
except UserNotExistError:
|
||||
raise HTTPException(status_code=401, detail=t("user_not_found"))
|
||||
except Exception as e:
|
||||
# Check if it's a RayTaskError wrapping UserNotExistError
|
||||
if "UserNotExistError" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=401, detail=t("user_not_found")
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
class RoleChecker:
|
||||
"""FastAPI 依赖:在路由级别按 ``UserAuthority`` 做最低权限校验。
|
||||
|
||||
例:``Depends(RoleChecker(allowed_roles=UserAuthority.ADMINISTRATOR))``。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.allowed_roles = kwargs.get(
|
||||
"allowed_roles",
|
||||
)
|
||||
|
||||
async def __call__(
|
||||
self, token_data: Annotated[TokenData, Depends(Accessor.get_current_user)]
|
||||
):
|
||||
"""对当前请求执行权限比较,权限不足抛 403,否则把 ``TokenData`` 透传给路由。"""
|
||||
user_authority = await get_authority(token_data.user_id)
|
||||
if user_authority < self.allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"message": f"User {token_data.user_id} does not have allowed roles"
|
||||
},
|
||||
)
|
||||
return token_data
|
||||
+57
-34
@@ -1,57 +1,74 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Callable, Dict, List
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from kilostar.utils.logger import get_logger
|
||||
from kilostar.utils.settings import get_toolset_dir
|
||||
|
||||
logger = get_logger("get_tool")
|
||||
_tool_cache: Dict[str, Callable] = {}
|
||||
_manifest_cache: Optional[Dict[str, Dict]] = None
|
||||
|
||||
|
||||
def _load_manifests() -> Dict[str, Dict]:
|
||||
"""扫描所有 toolset 的 manifest.json,建立 tool_name → {toolset_dir, file} 的映射。"""
|
||||
global _manifest_cache
|
||||
if _manifest_cache is not None:
|
||||
return _manifest_cache
|
||||
|
||||
_manifest_cache = {}
|
||||
toolset_root = get_toolset_dir()
|
||||
if not toolset_root.exists():
|
||||
return _manifest_cache
|
||||
|
||||
for item in toolset_root.iterdir():
|
||||
if not item.is_dir() or item.name.startswith("__"):
|
||||
continue
|
||||
manifest_path = item / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
for tool in manifest.get("tools", []):
|
||||
tool_name = tool.get("name")
|
||||
if tool_name:
|
||||
_manifest_cache[tool_name] = {
|
||||
"toolset_dir": str(item),
|
||||
"toolset_name": item.name,
|
||||
"file": tool.get("file", f"{tool_name}.py"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read manifest {manifest_path}: {e}")
|
||||
|
||||
return _manifest_cache
|
||||
|
||||
|
||||
def _get_tool_func(tool_name: str) -> Callable | None:
|
||||
"""按名字从 ``kilostar/plugin/tool_plugin/<tool_name>/__init__.py`` 中加载工具函数。
|
||||
"""按名字从 toolset 中加载工具函数。
|
||||
|
||||
加载成功后会被缓存到模块级 ``_tool_cache``;找不到目录、找不到同名函数或
|
||||
导入失败都会记录日志并返回 ``None``。
|
||||
根据 manifest 找到工具所在的 toolset 和文件,动态加载模块并取出同名函数。
|
||||
"""
|
||||
func = _tool_cache.get(tool_name, None)
|
||||
func = _tool_cache.get(tool_name)
|
||||
if func:
|
||||
return func
|
||||
|
||||
tool_plugin_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"plugin",
|
||||
"tool_plugin",
|
||||
tool_name,
|
||||
)
|
||||
|
||||
if not os.path.exists(tool_plugin_dir) or not os.path.isdir(tool_plugin_dir):
|
||||
logger.error(f"Tool directory not found: {tool_plugin_dir}")
|
||||
manifests = _load_manifests()
|
||||
info = manifests.get(tool_name)
|
||||
if not info:
|
||||
logger.error(f"Tool '{tool_name}' not found in any toolset manifest")
|
||||
return None
|
||||
|
||||
init_file = os.path.join(tool_plugin_dir, "__init__.py")
|
||||
if not os.path.exists(init_file):
|
||||
logger.error(f"Tool init file not found: {init_file}")
|
||||
tool_file = os.path.join(info["toolset_dir"], info["file"])
|
||||
if not os.path.exists(tool_file):
|
||||
logger.error(f"Tool file not found: {tool_file}")
|
||||
return None
|
||||
|
||||
try:
|
||||
module_name = f"kilostar.plugin.tool_plugin.{tool_name}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, init_file)
|
||||
module_name = f"data.toolset.{info['toolset_name']}.{tool_name}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, tool_file)
|
||||
if spec is None or spec.loader is None:
|
||||
logger.error(f"Failed to create spec for {module_name}")
|
||||
return None
|
||||
@@ -70,7 +87,7 @@ def _get_tool_func(tool_name: str) -> Callable | None:
|
||||
_tool_cache[tool_name] = func
|
||||
return func
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load module {module_name}: {e}")
|
||||
logger.error(f"Failed to load module {tool_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -80,6 +97,12 @@ def del_tool_cache(tool_name: str) -> None:
|
||||
del _tool_cache[tool_name]
|
||||
|
||||
|
||||
def invalidate_manifest_cache() -> None:
|
||||
"""清除 manifest 缓存,下次加载时重新扫描磁盘。"""
|
||||
global _manifest_cache
|
||||
_manifest_cache = None
|
||||
|
||||
|
||||
def load_tools_from_list(tool_names: List[str] | None) -> List[Callable]:
|
||||
"""批量加载工具:传入工具名列表,返回成功加载到的函数对象列表(失败项被跳过)。"""
|
||||
if not tool_names:
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
设计原则:
|
||||
- 纯内存字典,无文件 IO,Ray 远程序列化零成本。
|
||||
- 支持环境变量 ``KILOSTAR_LANG`` 作为全局默认语言。
|
||||
- Agent system prompt 按 ``{locale}`` 分桶,调用方显式传入 locale。
|
||||
- API 层通过请求头 ``Accept-Language`` 解析首选语言。
|
||||
|
||||
当前支持:``zh`` (简体中文), ``en`` (English)。
|
||||
@@ -31,93 +30,6 @@ from kilostar.utils.settings import get_settings
|
||||
|
||||
_DEFAULT_LOCALE: str = get_settings().kilostar_lang
|
||||
|
||||
# ─── Agent System Prompts ──────────────────────────────────────────────────
|
||||
|
||||
_PROMPTS: Dict[str, Dict[str, str]] = {
|
||||
"regulatory_node": {
|
||||
"zh": (
|
||||
"你叫kilostar,是一个多智能体AI助手系统中的【监管节点 (Regulatory Node)】。\n"
|
||||
"你是系统中直接面向用户的对话节点,负责理解用户需求并提供高质量的回复。\n\n"
|
||||
"你的核心职责:\n"
|
||||
"1. 准确理解用户的意图,提供专业、友好且有帮助的回复。\n"
|
||||
"2. 如果你有可用工具,可以主动调用工具来辅助回答(如搜索、文件操作等)。\n"
|
||||
"3. 如果你收到工作流的执行报告,请将其转化为面向用户的清晰总结。\n"
|
||||
"4. 保持回复简洁、有结构,避免冗余信息。\n"
|
||||
"请保持专业、友好的沟通风格。"
|
||||
),
|
||||
"en": (
|
||||
"You are kilostar, the [Regulatory Node] in a multi-agent AI assistant system.\n"
|
||||
"You are the user-facing conversational node, responsible for understanding user needs and providing high-quality responses.\n\n"
|
||||
"Your core responsibilities:\n"
|
||||
"1. Accurately understand user intent and provide professional, friendly, and helpful replies.\n"
|
||||
"2. If tools are available, proactively use them to assist your responses (e.g., search, file operations).\n"
|
||||
"3. If you receive a workflow execution report, convert it into a clear user-facing summary.\n"
|
||||
"4. Keep responses concise, well-structured, and free of redundancy.\n"
|
||||
"Maintain a professional and friendly communication style."
|
||||
),
|
||||
},
|
||||
"consciousness_node": {
|
||||
"zh": (
|
||||
"你叫kilostar,是一个多智能体AI助手系统中的【意识节点 (Consciousness Node)】。\n"
|
||||
"你是系统的'高级规划师'和'架构师',负责处理监控节点分配过来的复杂任务。\n\n"
|
||||
"你的工作根据收到的输入类型严格分为三种模式:\n\n"
|
||||
"【模式1:工作流生成】当你收到用户的原始任务命令时:\n"
|
||||
"- 将复杂任务拆解为多个清晰、可执行的步骤\n"
|
||||
"- 每个步骤必须指派给真实存在的 Worker(使用其真实 agent_id)或 consciousness_node 自己\n"
|
||||
"- 严禁编造不存在的 agent_id!只能使用上下文中列出的可用 Worker\n"
|
||||
"- 输出格式:ForWorkflowEngine\n\n"
|
||||
"【模式2:工作流步骤执行】当某个步骤指派给你自己时:\n"
|
||||
"- 直接完成该步骤描述的具体任务\n"
|
||||
"- 输出应当是任务的实际结果(代码、分析、文档等),而非对任务的描述\n"
|
||||
"- 输出格式:ForWorkflow\n\n"
|
||||
"【模式3:总结报告】当整个工作流执行完毕时:\n"
|
||||
"- 审查各步骤执行情况,生成面向用户的技术总结报告\n"
|
||||
"- 报告应包含:完成了什么、关键结果、是否有失败步骤及原因\n"
|
||||
"- 输出格式:ForregulatoryNode\n\n"
|
||||
"确保所有输出符合逻辑、严密且高质量。"
|
||||
),
|
||||
"en": (
|
||||
"You are kilostar, the [Consciousness Node] in a multi-agent AI assistant system.\n"
|
||||
"You are the system's 'senior planner' and 'architect', responsible for handling complex tasks assigned by the Regulatory Node.\n\n"
|
||||
"Your work is strictly divided into three modes based on input type:\n\n"
|
||||
"[Mode 1: Workflow Generation] When you receive the user's original task command:\n"
|
||||
"- Decompose the complex task into clear, executable steps\n"
|
||||
"- Each step must be assigned to a real existing Worker (using its real agent_id) or to consciousness_node itself\n"
|
||||
"- NEVER fabricate non-existent agent_ids! Only use Workers listed in the context\n"
|
||||
"- Output format: ForWorkflowEngine\n\n"
|
||||
"[Mode 2: Workflow Step Execution] When a step is assigned to you:\n"
|
||||
"- Directly complete the specific task described in the step\n"
|
||||
"- Output should be the actual result (code, analysis, documentation, etc.), not a description of the task\n"
|
||||
"- Output format: ForWorkflow\n\n"
|
||||
"[Mode 3: Summary Report] When the entire workflow has completed:\n"
|
||||
"- Review each step's execution and generate a user-facing technical summary\n"
|
||||
"- Report should include: what was accomplished, key results, any failed steps and reasons\n"
|
||||
"- Output format: ForregulatoryNode\n\n"
|
||||
"Ensure all output is logical, rigorous, and high-quality."
|
||||
),
|
||||
},
|
||||
"control_node": {
|
||||
"zh": (
|
||||
"你叫kilostar,是一个多智能体AI助手系统中的【控制节点 (Control Node)】。\n"
|
||||
"你是系统的'执行者'和'车间主任',专门负责执行工作流中分配给你的具体子任务。\n"
|
||||
"你的工作职责是:\n"
|
||||
"1. 仔细分析分配给你的工作流步骤 (workflow_step) 的目标和要求。\n"
|
||||
"2. 运用你被分配的工具 (如有) 或者依靠自身的知识和推理能力,精准、高效地完成该任务。\n"
|
||||
"3. 将执行的结果、产生的数据或者具体的输出,严格按照 ForWorkflow 格式返回。\n"
|
||||
"请注意:你的输出应当具体、实用,直接提供任务所要求的结果,不要做过多无关的寒暄。"
|
||||
),
|
||||
"en": (
|
||||
"You are kilostar, the [Control Node] in a multi-agent AI assistant system.\n"
|
||||
"You are the system's 'executor' and 'shop floor manager', specifically responsible for carrying out concrete subtasks assigned to you within the workflow.\n"
|
||||
"Your duties are:\n"
|
||||
"1. Carefully analyze the objectives and requirements of the workflow_step assigned to you.\n"
|
||||
"2. Use the tools assigned to you (if any) or rely on your own knowledge and reasoning to complete the task accurately and efficiently.\n"
|
||||
"3. Return the execution results, generated data, or concrete outputs strictly in the ForWorkflow format.\n"
|
||||
"Note: Your output should be specific, practical, and directly provide the results requested by the task. Avoid excessive irrelevant pleasantries."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# ─── API / 通用消息 ────────────────────────────────────────────────────────
|
||||
|
||||
_MESSAGES: Dict[str, Dict[str, str]] = {
|
||||
@@ -158,7 +70,6 @@ def _resolve_locale(locale: str | None = None, accept_language: str | None = Non
|
||||
if locale:
|
||||
return locale if locale in ("zh", "en") else _DEFAULT_LOCALE
|
||||
if accept_language:
|
||||
# 简单解析:取第一个 segment,若含 zh 则 zh,含 en 则 en
|
||||
first = accept_language.split(",")[0].split(";")[0].strip().lower()
|
||||
if "zh" in first:
|
||||
return "zh"
|
||||
@@ -182,26 +93,3 @@ def t(key: str, locale: str | None = None, accept_language: str | None = None, *
|
||||
loc = _resolve_locale(locale, accept_language)
|
||||
text = _MESSAGES.get(loc, {}).get(key) or _MESSAGES.get(_DEFAULT_LOCALE, {}).get(key) or key
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
|
||||
|
||||
def agent_prompt(
|
||||
agent_name: str,
|
||||
locale: str | None = None,
|
||||
accept_language: str | None = None,
|
||||
custom_system_prompt: str | None = None,
|
||||
) -> str:
|
||||
"""获取指定 Agent 的 system prompt,并追加语言指令。
|
||||
|
||||
若 ``custom_system_prompt`` 不为空,追加在默认 prompt 和语言指令之后,
|
||||
使管理员自定义内容能够覆盖/补充默认行为,同时保留角色定义。
|
||||
"""
|
||||
loc = _resolve_locale(locale, accept_language)
|
||||
prompt = _PROMPTS.get(agent_name, {}).get(loc) or _PROMPTS.get(agent_name, {}).get(_DEFAULT_LOCALE, "")
|
||||
lang_instruction = {
|
||||
"zh": "\n\n【重要】请始终使用简体中文进行思考和回复。",
|
||||
"en": "\n\n[Important] Please always think and reply in English.",
|
||||
}.get(loc, "")
|
||||
result = prompt + lang_instruction
|
||||
if custom_system_prompt and custom_system_prompt.strip():
|
||||
result += f"\n\n{custom_system_prompt.strip()}"
|
||||
return result
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from typing import Type, TypeVar
|
||||
from pydantic import BaseModel
|
||||
|
||||
T = TypeVar("T", bound=Type[BaseModel])
|
||||
|
||||
|
||||
def pickle(cls: T) -> T:
|
||||
"""
|
||||
类装饰器pickle
|
||||
通过装饰继承了BaseModel的类,用pydantic的高效序列化替代python原生__reduce__魔术方法,实现ray在通讯时的高效序列化
|
||||
Args:
|
||||
cls: 继承了BaseModel类的类,需要被装饰的对象
|
||||
|
||||
Returns:
|
||||
返回被重写了__reduce__魔术方法的cls类
|
||||
"""
|
||||
|
||||
def __reduce__(self):
|
||||
# 1. 序列化:触发 Pydantic-core (Rust) 的极速序列化
|
||||
data = self.model_dump_json()
|
||||
# 2. 反序列化:告诉 Pickle 重建时调用 cls.model_validate_json
|
||||
return cls.model_validate_json, (data,)
|
||||
|
||||
cls.__reduce__ = __reduce__
|
||||
return cls
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Agent system prompt 模板。
|
||||
|
||||
按 agent 角色 × locale 组织,供各节点初始化时获取对应 system prompt。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from kilostar.utils.i18n import _resolve_locale
|
||||
|
||||
_PROMPTS: Dict[str, Dict[str, str]] = {
|
||||
"regulatory_node": {
|
||||
"zh": (
|
||||
"你叫kilostar,是一个多智能体AI助手系统中的【监管节点 (Regulatory Node)】。\n"
|
||||
"你是系统中直接面向用户的对话节点,负责理解用户需求并提供高质量的回复。\n\n"
|
||||
"你的核心职责:\n"
|
||||
"1. 准确理解用户的意图,提供专业、友好且有帮助的回复。\n"
|
||||
"2. 如果你有可用工具,可以主动调用工具来辅助回答(如搜索、文件操作等)。\n"
|
||||
"3. 如果你收到工作流的执行报告,请将其转化为面向用户的清晰总结。\n"
|
||||
"4. 保持回复简洁、有结构,避免冗余信息。\n"
|
||||
"请保持专业、友好的沟通风格。"
|
||||
),
|
||||
"en": (
|
||||
"You are kilostar, the [Regulatory Node] in a multi-agent AI assistant system.\n"
|
||||
"You are the user-facing conversational node, responsible for understanding user needs and providing high-quality responses.\n\n"
|
||||
"Your core responsibilities:\n"
|
||||
"1. Accurately understand user intent and provide professional, friendly, and helpful replies.\n"
|
||||
"2. If tools are available, proactively use them to assist your responses (e.g., search, file operations).\n"
|
||||
"3. If you receive a workflow execution report, convert it into a clear user-facing summary.\n"
|
||||
"4. Keep responses concise, well-structured, and free of redundancy.\n"
|
||||
"Maintain a professional and friendly communication style."
|
||||
),
|
||||
},
|
||||
"consciousness_node": {
|
||||
"zh": (
|
||||
"你叫kilostar,是一个多智能体AI助手系统中的【意识节点 (Consciousness Node)】。\n"
|
||||
"你是系统的'高级规划师'和'架构师',负责处理监控节点分配过来的复杂任务。\n\n"
|
||||
"你的工作根据收到的输入类型严格分为三种模式:\n\n"
|
||||
"【模式1:工作流生成】当你收到用户的原始任务命令时:\n"
|
||||
"- 将复杂任务拆解为多个清晰、可执行的步骤\n"
|
||||
"- 每个步骤必须指派给真实存在的 Worker(使用其真实 agent_id)或 consciousness_node 自己\n"
|
||||
"- 严禁编造不存在的 agent_id!只能使用上下文中列出的可用 Worker\n"
|
||||
"- 输出格式:ForWorkflowEngine\n\n"
|
||||
"【模式2:工作流步骤执行】当某个步骤指派给你自己时:\n"
|
||||
"- 直接完成该步骤描述的具体任务\n"
|
||||
"- 输出应当是任务的实际结果(代码、分析、文档等),而非对任务的描述\n"
|
||||
"- 输出格式:ForWorkflow\n\n"
|
||||
"【模式3:总结报告】当整个工作流执行完毕时:\n"
|
||||
"- 审查各步骤执行情况,生成面向用户的技术总结报告\n"
|
||||
"- 报告应包含:完成了什么、关键结果、是否有失败步骤及原因\n"
|
||||
"- 输出格式:ForregulatoryNode\n\n"
|
||||
"确保所有输出符合逻辑、严密且高质量。"
|
||||
),
|
||||
"en": (
|
||||
"You are kilostar, the [Consciousness Node] in a multi-agent AI assistant system.\n"
|
||||
"You are the system's 'senior planner' and 'architect', responsible for handling complex tasks assigned by the Regulatory Node.\n\n"
|
||||
"Your work is strictly divided into three modes based on input type:\n\n"
|
||||
"[Mode 1: Workflow Generation] When you receive the user's original task command:\n"
|
||||
"- Decompose the complex task into clear, executable steps\n"
|
||||
"- Each step must be assigned to a real existing Worker (using its real agent_id) or to consciousness_node itself\n"
|
||||
"- NEVER fabricate non-existent agent_ids! Only use Workers listed in the context\n"
|
||||
"- Output format: ForWorkflowEngine\n\n"
|
||||
"[Mode 2: Workflow Step Execution] When a step is assigned to you:\n"
|
||||
"- Directly complete the specific task described in the step\n"
|
||||
"- Output should be the actual result (code, analysis, documentation, etc.), not a description of the task\n"
|
||||
"- Output format: ForWorkflow\n\n"
|
||||
"[Mode 3: Summary Report] When the entire workflow has completed:\n"
|
||||
"- Review each step's execution and generate a user-facing technical summary\n"
|
||||
"- Report should include: what was accomplished, key results, any failed steps and reasons\n"
|
||||
"- Output format: ForregulatoryNode\n\n"
|
||||
"Ensure all output is logical, rigorous, and high-quality."
|
||||
),
|
||||
},
|
||||
"control_node": {
|
||||
"zh": (
|
||||
"你叫kilostar,是一个多智能体AI助手系统中的【控制节点 (Control Node)】。\n"
|
||||
"你是系统的'执行者'和'车间主任',专门负责执行工作流中分配给你的具体子任务。\n"
|
||||
"你的工作职责是:\n"
|
||||
"1. 仔细分析分配给你的工作流步骤 (workflow_step) 的目标和要求。\n"
|
||||
"2. 运用你被分配的工具 (如有) 或者依靠自身的知识和推理能力,精准、高效地完成该任务。\n"
|
||||
"3. 将执行的结果、产生的数据或者具体的输出,严格按照 ForWorkflow 格式返回。\n"
|
||||
"请注意:你的输出应当具体、实用,直接提供任务所要求的结果,不要做过多无关的寒暄。"
|
||||
),
|
||||
"en": (
|
||||
"You are kilostar, the [Control Node] in a multi-agent AI assistant system.\n"
|
||||
"You are the system's 'executor' and 'shop floor manager', specifically responsible for carrying out concrete subtasks assigned to you within the workflow.\n"
|
||||
"Your duties are:\n"
|
||||
"1. Carefully analyze the objectives and requirements of the workflow_step assigned to you.\n"
|
||||
"2. Use the tools assigned to you (if any) or rely on your own knowledge and reasoning to complete the task accurately and efficiently.\n"
|
||||
"3. Return the execution results, generated data, or concrete outputs strictly in the ForWorkflow format.\n"
|
||||
"Note: Your output should be specific, practical, and directly provide the results requested by the task. Avoid excessive irrelevant pleasantries."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def agent_prompt(
|
||||
agent_name: str,
|
||||
locale: str | None = None,
|
||||
accept_language: str | None = None,
|
||||
custom_system_prompt: str | None = None,
|
||||
) -> str:
|
||||
"""获取指定 Agent 的 system prompt,并追加语言指令。
|
||||
|
||||
若 ``custom_system_prompt`` 不为空,追加在默认 prompt 和语言指令之后,
|
||||
使管理员自定义内容能够覆盖/补充默认行为,同时保留角色定义。
|
||||
"""
|
||||
from kilostar.utils.settings import get_settings
|
||||
|
||||
_DEFAULT_LOCALE = get_settings().kilostar_lang
|
||||
loc = _resolve_locale(locale, accept_language)
|
||||
prompt = _PROMPTS.get(agent_name, {}).get(loc) or _PROMPTS.get(agent_name, {}).get(_DEFAULT_LOCALE, "")
|
||||
lang_instruction = {
|
||||
"zh": "\n\n【重要】请始终使用简体中文进行思考和回复。",
|
||||
"en": "\n\n[Important] Please always think and reply in English.",
|
||||
}.get(loc, "")
|
||||
result = prompt + lang_instruction
|
||||
if custom_system_prompt and custom_system_prompt.strip():
|
||||
result += f"\n\n{custom_system_prompt.strip()}"
|
||||
return result
|
||||
@@ -1,4 +1,4 @@
|
||||
"""KiloStar 单机模式适配层:用 asyncio 协程模拟 Ray Actor 接口。
|
||||
"""KiloStar Ray 兼容层:单机/分布式模式无感切换 + 序列化工具。
|
||||
|
||||
单机模式下,所有 Actor 退化为普通 Python 异步单例,通过 StandaloneProxy
|
||||
包装后暴露与 Ray Actor Handle 相同的 `.method.remote(args)` 调用接口,
|
||||
@@ -9,10 +9,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Any, Type, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
_STANDALONE = os.environ.get("KILOSTAR_MODE", "distributed") == "standalone"
|
||||
|
||||
T = TypeVar("T", bound=Type[BaseModel])
|
||||
|
||||
|
||||
class _MethodProxy:
|
||||
"""包装单个方法,使 .remote(*args, **kwargs) 返回一个可 await 的 Task。"""
|
||||
@@ -84,3 +88,19 @@ def remote_task(func):
|
||||
|
||||
import ray
|
||||
return ray.remote(func)
|
||||
|
||||
|
||||
# ─── Pickle (Ray 序列化优化) ───
|
||||
|
||||
|
||||
def pickle(cls: T) -> T:
|
||||
"""类装饰器:用 Pydantic 的高效 JSON 序列化替代 Python 原生 __reduce__,
|
||||
使 Ray 跨进程通信时对 BaseModel 子类走 Rust 级序列化。
|
||||
"""
|
||||
|
||||
def __reduce__(self):
|
||||
data = self.model_dump_json()
|
||||
return cls.model_validate_json, (data,)
|
||||
|
||||
cls.__reduce__ = __reduce__
|
||||
return cls
|
||||
@@ -15,7 +15,7 @@ import time
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict
|
||||
|
||||
from kilostar.utils.standalone_proxy import _STANDALONE
|
||||
from kilostar.utils.ray_compat import _STANDALONE
|
||||
|
||||
if not _STANDALONE:
|
||||
import ray
|
||||
@@ -49,7 +49,7 @@ _standalone_registry: Dict[str, Any] = {}
|
||||
|
||||
def register_standalone(name: str, instance: Any) -> None:
|
||||
"""注册一个单机模式下的 Actor 单例(已包装为 StandaloneProxy)。"""
|
||||
from kilostar.utils.standalone_proxy import StandaloneProxy
|
||||
from kilostar.utils.ray_compat import StandaloneProxy
|
||||
|
||||
_standalone_registry[name] = StandaloneProxy(instance)
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ class AppSettings(BaseSettings):
|
||||
kilostar_mode: str = "distributed"
|
||||
kilostar_lang: str = "zh"
|
||||
kilostar_cors_origins: str = ""
|
||||
kilostar_plugin_dir: str = ""
|
||||
kilostar_toolset_dir: str = ""
|
||||
kilostar_artifact_dir: str = ""
|
||||
|
||||
db: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||
security: SecuritySettings = Field(default_factory=SecuritySettings)
|
||||
@@ -53,3 +56,46 @@ class AppSettings(BaseSettings):
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> AppSettings:
|
||||
return AppSettings()
|
||||
|
||||
|
||||
def get_plugin_dir() -> "pathlib.Path":
|
||||
"""返回插件根目录路径(包含 tool_plugin/ 和 skill/ 子目录)。
|
||||
|
||||
优先使用环境变量 KILOSTAR_PLUGIN_DIR,否则默认 <project_root>/data/plugin/。
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
custom = get_settings().kilostar_plugin_dir
|
||||
if custom:
|
||||
return pathlib.Path(custom)
|
||||
project_root = pathlib.Path(__file__).parent.parent.parent
|
||||
return project_root / "data" / "plugin"
|
||||
|
||||
|
||||
def get_toolset_dir() -> "pathlib.Path":
|
||||
"""返回工具集根目录路径(包含各 toolset 子目录,如 base_toolset/)。
|
||||
|
||||
优先使用环境变量 KILOSTAR_TOOLSET_DIR,否则默认 <project_root>/data/toolset/。
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
custom = get_settings().kilostar_toolset_dir
|
||||
if custom:
|
||||
return pathlib.Path(custom)
|
||||
project_root = pathlib.Path(__file__).parent.parent.parent
|
||||
return project_root / "data" / "toolset"
|
||||
|
||||
|
||||
def get_artifact_dir() -> "pathlib.Path":
|
||||
"""返回工作流产物(agent 通过 send_file 推送的文件)存放根目录。
|
||||
|
||||
优先使用环境变量 KILOSTAR_ARTIFACT_DIR,否则默认 <project_root>/data/artifact/。
|
||||
每个 trace_id 一个子目录,下载链接走 /api/v1/resource/artifact/{trace_id}/{aid}。
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
custom = get_settings().kilostar_artifact_dir
|
||||
if custom:
|
||||
return pathlib.Path(custom)
|
||||
project_root = pathlib.Path(__file__).parent.parent.parent
|
||||
return project_root / "data" / "artifact"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import time
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from kilostar.utils.standalone_proxy import actor_class, _STANDALONE
|
||||
from kilostar.utils.ray_compat import actor_class, _STANDALONE
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
if _STANDALONE:
|
||||
|
||||
@@ -35,9 +35,10 @@ class SkillIndividual(BaseIndividual):
|
||||
|
||||
async def _load_skill_tools(self):
|
||||
"""动态加载已绑定的 skill 工具。"""
|
||||
from kilostar.utils.settings import get_plugin_dir
|
||||
|
||||
tools = []
|
||||
bound_skill = self.agent_config.get("bound_skill", "")
|
||||
# bound_skill can be string or dict {"skill_name": ["file1", "file2"]}
|
||||
skill_mapper = {}
|
||||
if isinstance(bound_skill, str) and bound_skill:
|
||||
try:
|
||||
@@ -47,9 +48,7 @@ class SkillIndividual(BaseIndividual):
|
||||
elif isinstance(bound_skill, dict):
|
||||
skill_mapper = bound_skill
|
||||
|
||||
skill_base_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "plugin", "skill")
|
||||
)
|
||||
skill_base_dir = str(get_plugin_dir() / "skill")
|
||||
|
||||
for skill_name, _ in skill_mapper.items():
|
||||
skill_path = os.path.join(skill_base_dir, skill_name)
|
||||
|
||||
@@ -41,6 +41,7 @@ from kilostar.core.global_state_machine import GlobalStateMachine
|
||||
from kilostar.core.global_workflow_manager import GlobalWorkflowManager
|
||||
from kilostar.core.individual.regulatory_node import RegulatoryNode
|
||||
from kilostar.core.individual.consciousness_node import ConsciousnessNode
|
||||
from kilostar.plugin_runtime.plugin_manager import GlobalPluginManager
|
||||
|
||||
if KILOSTAR_MODE != "standalone":
|
||||
import ray
|
||||
@@ -58,7 +59,7 @@ async def start_standalone():
|
||||
await postgres_database.init_db()
|
||||
register_standalone("postgres_database", postgres_database)
|
||||
|
||||
from kilostar.utils.standalone_proxy import StandaloneProxy
|
||||
from kilostar.utils.ray_compat import StandaloneProxy
|
||||
postgres_proxy = StandaloneProxy(postgres_database)
|
||||
|
||||
global_state_machine = GlobalStateMachine(postgres_proxy)
|
||||
@@ -83,6 +84,10 @@ async def start_standalone():
|
||||
register_standalone("worker_cluster_core", worker_cluster)
|
||||
register_standalone("worker_cluster_gpu", worker_cluster)
|
||||
|
||||
plugin_manager = GlobalPluginManager()
|
||||
await plugin_manager.bootstrap()
|
||||
register_standalone("global_plugin_manager", plugin_manager)
|
||||
|
||||
print(f"✅ KiloStar 单机模式启动完成,监听 0.0.0.0:8000")
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=8000, log_level="info")
|
||||
@@ -162,6 +167,15 @@ async def start_distributed():
|
||||
print(f"\n[致命错误] GlobalWorkflowManager 启动失败!\n{e}\n")
|
||||
return
|
||||
|
||||
plugin_manager = GlobalPluginManager.options(
|
||||
name="global_plugin_manager", namespace="kilostar", lifetime="detached"
|
||||
).remote()
|
||||
try:
|
||||
await plugin_manager.bootstrap.remote()
|
||||
print("✅ GlobalPluginManager 初始化成功!")
|
||||
except Exception as e:
|
||||
print(f"⚠️ GlobalPluginManager 启动失败(非致命): {e}")
|
||||
|
||||
serve.start(http_options={"host": "0.0.0.0", "port": 8000})
|
||||
serve.run(KiloStarGateway.bind())
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ def _tpl(owner: str = "alice", is_builtin: bool = False):
|
||||
|
||||
@pytest.fixture
|
||||
def app(monkeypatch):
|
||||
import kilostar.utils.check_user.role_check as rc
|
||||
monkeypatch.setattr(rc, "get_authority", AsyncMock(return_value=UserAuthority.USER))
|
||||
import kilostar.utils.access as access_mod
|
||||
monkeypatch.setattr(access_mod, "get_authority", AsyncMock(return_value=UserAuthority.USER))
|
||||
_app = FastAPI()
|
||||
_app.include_router(agent_router)
|
||||
_app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user()
|
||||
|
||||
@@ -21,17 +21,16 @@ def _fake_user(user_id: str = "alice"):
|
||||
@pytest.fixture
|
||||
def app_with_user(monkeypatch):
|
||||
"""挂上 resource_router;用 dependency_overrides 跳过 JWT,并把 get_authority 默认放成 USER。"""
|
||||
import kilostar.utils.access as access_mod
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(resource_router)
|
||||
app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user("alice")
|
||||
|
||||
# 默认把权限置为 USER;具体 case 内部可再 monkeypatch 覆盖
|
||||
async def _default_authority(uid):
|
||||
return UserAuthority.USER
|
||||
|
||||
monkeypatch.setattr(
|
||||
"kilostar.utils.check_user.role_check.get_authority", _default_authority
|
||||
)
|
||||
monkeypatch.setattr(access_mod, "get_authority", _default_authority)
|
||||
return app
|
||||
|
||||
|
||||
@@ -87,9 +86,8 @@ async def test_get_custom_toolset_allowed_for_admin(
|
||||
async def _admin(uid):
|
||||
return UserAuthority.SUPER_ADMINISTRATOR
|
||||
|
||||
monkeypatch.setattr(
|
||||
"kilostar.utils.check_user.role_check.get_authority", _admin
|
||||
)
|
||||
import kilostar.utils.access as access_mod
|
||||
monkeypatch.setattr(access_mod, "get_authority", _admin)
|
||||
|
||||
transport = ASGITransport(app=app_with_user)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
||||
@@ -1,41 +1,89 @@
|
||||
"""``plugin/tool_plugin`` 下各工具的元数据类正确性。
|
||||
"""``data/toolset/`` manifest.json 加载正确性测试。
|
||||
|
||||
``BaseToolData`` 本身不带 ``name`` 字段;工具名以目录名为准(由 ``GlobalToolManager``
|
||||
扫描时注入到 ``tool_metadata`` 中)。这里只验证子类对 BaseToolData 字段的覆写。
|
||||
验证 GlobalToolManager 从 manifest.json 正确读取工具元数据。
|
||||
"""
|
||||
|
||||
from kilostar.plugin.tool_plugin.approval.approval import ApprovalToolData
|
||||
from kilostar.plugin.tool_plugin.file_reader import FileReaderToolData
|
||||
from kilostar.plugin.tool_plugin.tavily_search import TavilySearchToolData
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_toolset_dir = Path(__file__).parent.parent.parent / "data" / "toolset"
|
||||
_base_toolset_dir = _toolset_dir / "base_toolset"
|
||||
_interactive_toolset_dir = _toolset_dir / "interactive_toolset"
|
||||
|
||||
|
||||
def _read_manifest(toolset_dir=_base_toolset_dir):
|
||||
with open(toolset_dir / "manifest.json", "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _get_tool_def(manifest, name):
|
||||
for tool in manifest["tools"]:
|
||||
if tool["name"] == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
def test_manifest_json_exists():
|
||||
assert (_base_toolset_dir / "manifest.json").exists()
|
||||
assert (_interactive_toolset_dir / "manifest.json").exists()
|
||||
|
||||
|
||||
def test_manifest_has_all_tools():
|
||||
base_names = {t["name"] for t in _read_manifest(_base_toolset_dir)["tools"]}
|
||||
interactive_names = {
|
||||
t["name"] for t in _read_manifest(_interactive_toolset_dir)["tools"]
|
||||
}
|
||||
assert base_names == {
|
||||
"shell_executor", "file_reader", "edit_file", "write_file",
|
||||
"search_file", "python_executor", "tavily_search",
|
||||
}
|
||||
assert interactive_names == {"approval", "send_file"}
|
||||
|
||||
|
||||
def test_approval_metadata():
|
||||
data = ApprovalToolData()
|
||||
assert data.is_system is True
|
||||
assert data.category == "system"
|
||||
# action_scope 为空表示分配给 default 组(所有节点可用)
|
||||
assert data.action_scope == []
|
||||
|
||||
|
||||
def test_file_reader_metadata():
|
||||
data = FileReaderToolData()
|
||||
assert data.is_system is True
|
||||
assert data.category == "system"
|
||||
assert data.action_scope == []
|
||||
manifest = _read_manifest(_interactive_toolset_dir)
|
||||
tool = _get_tool_def(manifest, "approval")
|
||||
assert tool["is_system"] is True
|
||||
assert tool["category"] == "system"
|
||||
assert tool["action_scope"] == []
|
||||
|
||||
|
||||
def test_tavily_search_metadata():
|
||||
data = TavilySearchToolData()
|
||||
assert data.is_system is False
|
||||
assert data.category == "search"
|
||||
assert "control_node" in data.action_scope
|
||||
assert "consciousness_node" in data.action_scope
|
||||
# 默认配置 schema 含 api_key 字段(用于 GSM 配置面板)
|
||||
assert "api_key" in data.config_args
|
||||
manifest = _read_manifest()
|
||||
tool = _get_tool_def(manifest, "tavily_search")
|
||||
assert tool["is_system"] is False
|
||||
assert tool["category"] == "search"
|
||||
assert "control_node" in tool["action_scope"]
|
||||
assert "consciousness_node" in tool["action_scope"]
|
||||
assert "api_key" in tool["config_args"]
|
||||
|
||||
|
||||
def test_base_tool_extra_allowed():
|
||||
"""``ConfigDict(extra="allow")`` 允许子类外的 KV 也能装进来。"""
|
||||
data = ApprovalToolData(some_extension="ok") # type: ignore[call-arg]
|
||||
assert data.model_extra is not None
|
||||
assert data.model_extra.get("some_extension") == "ok"
|
||||
def test_all_tool_files_exist():
|
||||
for toolset_dir in (_base_toolset_dir, _interactive_toolset_dir):
|
||||
manifest = _read_manifest(toolset_dir)
|
||||
for tool in manifest["tools"]:
|
||||
file_path = toolset_dir / tool["file"]
|
||||
assert file_path.exists(), f"Missing tool file: {tool['file']}"
|
||||
|
||||
|
||||
def test_tool_manager_loads_all_tools():
|
||||
from kilostar.core.global_state_machine.tool_manager import GlobalToolManager
|
||||
|
||||
tm = GlobalToolManager()
|
||||
assert len(tm.tool_metadata) == 9
|
||||
assert "shell_executor" in tm.tool_metadata
|
||||
assert "tavily_search" in tm.tool_metadata
|
||||
assert "approval" in tm.tool_metadata
|
||||
|
||||
|
||||
def test_tool_manager_system_vs_third_party():
|
||||
from kilostar.core.global_state_machine.tool_manager import GlobalToolManager
|
||||
|
||||
tm = GlobalToolManager()
|
||||
system_tools = tm.get_system_tools()
|
||||
third_party = tm.get_third_party_tools()
|
||||
system_names = {t["name"] for t in system_tools}
|
||||
tp_names = {t["name"] for t in third_party}
|
||||
assert "shell_executor" in system_names
|
||||
assert "tavily_search" in tp_names
|
||||
assert "tavily_search" not in system_names
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for the heavy plugin (Organization) runtime."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from kilostar.plugin_runtime.manifest import OrgManifest
|
||||
from kilostar.plugin_runtime.agents_config import AgentsConfig
|
||||
from kilostar.plugin_runtime.loader import discover_plugins, load_plugin
|
||||
from kilostar.plugin_runtime.tool_bridge import make_dispatch_tool
|
||||
|
||||
_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "data" / "plugin"
|
||||
_EXAMPLE_DEPT = _PLUGIN_ROOT / "example_dept"
|
||||
|
||||
|
||||
def test_example_dept_structure():
|
||||
"""example_dept stub has the required files."""
|
||||
assert (_EXAMPLE_DEPT / "manifest.json").exists()
|
||||
assert (_EXAMPLE_DEPT / "agents.json").exists()
|
||||
assert (_EXAMPLE_DEPT / "README.md").exists()
|
||||
assert (_EXAMPLE_DEPT / "core" / "organization.py").exists()
|
||||
|
||||
|
||||
def test_example_dept_manifest_valid():
|
||||
with open(_EXAMPLE_DEPT / "manifest.json", "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
manifest = OrgManifest.model_validate(data)
|
||||
assert manifest.name == "example_dept"
|
||||
assert manifest.actor_name == "org_example_dept"
|
||||
assert manifest.entry == "core.organization:ExampleOrganization"
|
||||
|
||||
|
||||
def test_example_dept_agents_valid():
|
||||
with open(_EXAMPLE_DEPT / "agents.json", "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
config = AgentsConfig.model_validate(data)
|
||||
assert len(config.agents) == 2
|
||||
names = {a.name for a in config.agents}
|
||||
assert names == {"analyst", "executor"}
|
||||
assert config.orchestration.entry == "analyst"
|
||||
analyst = config.get("analyst")
|
||||
assert analyst is not None
|
||||
assert "executor" in analyst.peers
|
||||
|
||||
|
||||
def test_discover_plugins_finds_example_dept():
|
||||
plugins = discover_plugins(_PLUGIN_ROOT)
|
||||
names = {p.name for p in plugins}
|
||||
assert "example_dept" in names
|
||||
|
||||
|
||||
def test_load_plugin_returns_class():
|
||||
cls, manifest_dict, agents_dict, dir_str = load_plugin(_EXAMPLE_DEPT)
|
||||
assert cls.__name__ == "ExampleOrganization"
|
||||
assert manifest_dict["name"] == "example_dept"
|
||||
assert "agents" in agents_dict
|
||||
assert dir_str == str(_EXAMPLE_DEPT)
|
||||
|
||||
|
||||
def test_make_dispatch_tool_signature():
|
||||
tool = make_dispatch_tool("example_dept", "示例部门", "演示用")
|
||||
assert tool.__name__ == "dispatch_to_example_dept"
|
||||
assert callable(tool)
|
||||
assert "演示用" in tool.__doc__
|
||||
@@ -1,4 +1,4 @@
|
||||
"""standalone_proxy 适配层单元测试。
|
||||
"""ray_compat 适配层单元测试。
|
||||
|
||||
验证 StandaloneProxy / _MethodProxy / actor_class / remote_task
|
||||
在单机模式下的行为是否正确模拟了 Ray Actor Handle 的 .remote() 接口。
|
||||
@@ -7,8 +7,8 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from kilostar.utils import standalone_proxy
|
||||
from kilostar.utils.standalone_proxy import StandaloneProxy, _MethodProxy
|
||||
from kilostar.utils import ray_compat
|
||||
from kilostar.utils.ray_compat import StandaloneProxy, _MethodProxy
|
||||
|
||||
|
||||
class TestMethodProxy:
|
||||
@@ -61,9 +61,9 @@ class TestStandaloneProxy:
|
||||
|
||||
class TestActorClass:
|
||||
def test_standalone_returns_class_unchanged(self, monkeypatch):
|
||||
monkeypatch.setattr(standalone_proxy, "_STANDALONE", True)
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
|
||||
@standalone_proxy.actor_class
|
||||
@ray_compat.actor_class
|
||||
class MyActor:
|
||||
def do_work(self):
|
||||
return 42
|
||||
@@ -72,9 +72,9 @@ class TestActorClass:
|
||||
assert instance.do_work() == 42
|
||||
|
||||
def test_standalone_class_is_plain_python(self, monkeypatch):
|
||||
monkeypatch.setattr(standalone_proxy, "_STANDALONE", True)
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
|
||||
@standalone_proxy.actor_class
|
||||
@ray_compat.actor_class
|
||||
class MyActor:
|
||||
pass
|
||||
|
||||
@@ -84,9 +84,9 @@ class TestActorClass:
|
||||
|
||||
class TestRemoteTask:
|
||||
def test_sync_task(self, monkeypatch):
|
||||
monkeypatch.setattr(standalone_proxy, "_STANDALONE", True)
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
|
||||
@standalone_proxy.remote_task
|
||||
@ray_compat.remote_task
|
||||
def multiply(a, b):
|
||||
return a * b
|
||||
|
||||
@@ -95,9 +95,9 @@ class TestRemoteTask:
|
||||
assert result == 12
|
||||
|
||||
def test_async_task(self, monkeypatch):
|
||||
monkeypatch.setattr(standalone_proxy, "_STANDALONE", True)
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
|
||||
@standalone_proxy.remote_task
|
||||
@ray_compat.remote_task
|
||||
async def async_multiply(a, b):
|
||||
return a * b
|
||||
|
||||
Reference in New Issue
Block a user