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
|
.idea
|
||||||
# Local runtime data (MCP registry, etc.)
|
# Local runtime data (MCP registry, etc.)
|
||||||
data/
|
data/
|
||||||
|
!data/plugin/
|
||||||
|
data/plugin/skill/
|
||||||
|
!data/toolset/
|
||||||
tmp/
|
tmp/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
[](https://ai.pydantic.dev/)
|
[](https://ai.pydantic.dev/)
|
||||||
[](LICENSE)
|
[](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>
|
</div>
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
### 🧩 插件体系
|
### 🧩 插件体系
|
||||||
- **工具插件**:标准 Tool 调用,支持 MCP 协议接入第三方服务
|
- **工具插件**:标准 Tool 调用,支持 MCP 协议接入第三方服务
|
||||||
- **Skill(兼容 Anthropic Agent Skills 标准)**:通过 [viceroy](https://github.com/zhaoxi826/viceroy) 安装解析,运行时按需加载
|
- **Skill(兼容 Anthropic Agent Skills 标准)**:通过 [viceroy](https://github.com/zhaoxi826/viceroy) 安装解析,运行时按需加载
|
||||||
- **重型插件(规划中)**:带独立 UI 的垂直应用包,把 KiloStar 改造成专用 Agent 平台
|
- **重型插件(Organization)**:带独立工具集、多 Agent 团队与前端面板的垂直应用包,以"部门"身份接入系统内阁
|
||||||
|
|
||||||
### 🛡️ 安全设计
|
### 🛡️ 安全设计
|
||||||
- **JWT 鉴权**:所有 API 端点(含 SSE 事件流)均走 Bearer Token 认证
|
- **JWT 鉴权**:所有 API 端点(含 SSE 事件流)均走 Bearer Token 认证
|
||||||
@@ -129,58 +129,7 @@ cd frontend && npm install && npm run dev
|
|||||||
|
|
||||||
## 📁 项目结构
|
## 📁 项目结构
|
||||||
|
|
||||||
```
|
详见 [docs/STRUCTURE.md](docs/STRUCTURE.md)。
|
||||||
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/ # 设计文档
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { WorkflowDetail } from '../../types';
|
||||||
import { ErrorBoundary } from '../ErrorBoundary';
|
import { ErrorBoundary } from '../ErrorBoundary';
|
||||||
import { WorkflowDiagram } from './WorkflowDiagram';
|
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 {
|
interface RightPanelProps {
|
||||||
selectedWorkflow: string | null;
|
selectedWorkflow: string | null;
|
||||||
@@ -16,7 +45,7 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [detail, setDetail] = useState<WorkflowDetail | null>(null);
|
const [detail, setDetail] = useState<WorkflowDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [sseConnected, setSseConnected] = useState(false);
|
const [sseConnected, setSseConnected] = useState(false);
|
||||||
const [replyText, setReplyText] = useState('');
|
const [replyText, setReplyText] = useState('');
|
||||||
const [resuming, setResuming] = useState(false);
|
const [resuming, setResuming] = useState(false);
|
||||||
@@ -54,13 +83,13 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
|||||||
token,
|
token,
|
||||||
{
|
{
|
||||||
onOpen: () => setSseConnected(true),
|
onOpen: () => setSseConnected(true),
|
||||||
onMessage: (data) => setLogs((prev) => [...prev, data]),
|
onMessage: (data) => setLogs((prev) => [...prev, parseLogEvent(data)]),
|
||||||
onError: () => setSseConnected(false),
|
onError: () => setSseConnected(false),
|
||||||
onReconnect: (delayMs) => {
|
onReconnect: (delayMs) => {
|
||||||
setSseConnected(false);
|
setSseConnected(false);
|
||||||
setLogs((prev) => [
|
setLogs((prev) => [
|
||||||
...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;
|
if (!replyText.trim() || !selectedWorkflow) return;
|
||||||
const message = replyText.trim();
|
const message = replyText.trim();
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
setLogs((prev) => [...prev, `[You]: ${message}`]);
|
setLogs((prev) => [...prev, { kind: 'text', content: `[You]: ${message}` }]);
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/api/v1/workflow/reply/${selectedWorkflow}`, { message });
|
await apiClient.post(`/api/v1/workflow/reply/${selectedWorkflow}`, { message });
|
||||||
} catch {
|
} 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);
|
setResuming(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/api/v1/workflow/${selectedWorkflow}/resume`);
|
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);
|
fetchDetail(selectedWorkflow);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const detailMsg = err?.response?.data?.detail || t('workflow.resumeFailed');
|
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 {
|
} finally {
|
||||||
setResuming(false);
|
setResuming(false);
|
||||||
}
|
}
|
||||||
@@ -220,11 +249,16 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
|||||||
{t('workflow.waitingEvents')}
|
{t('workflow.waitingEvents')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log, index) => (
|
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'}`}>
|
if (log.kind === 'file') {
|
||||||
{log}
|
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>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
<div ref={logsEndRef} />
|
<div ref={logsEndRef} />
|
||||||
</div>
|
</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 { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Package, Wrench, Loader2, Box, Shield, X } from 'lucide-react';
|
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';
|
import apiClient from '../../api/client';
|
||||||
|
|
||||||
interface Toolset {
|
interface Toolset {
|
||||||
@@ -145,13 +147,36 @@ function ToolsetModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-primary">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border-primary">
|
||||||
@@ -172,7 +197,37 @@ function ToolsetModal({
|
|||||||
<X size={16} className="text-text-muted" />
|
<X size={16} className="text-text-muted" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 space-y-2 max-h-80 overflow-y-auto">
|
<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">
|
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||||
{t('plugin.toolsetTools')}
|
{t('plugin.toolsetTools')}
|
||||||
</p>
|
</p>
|
||||||
@@ -186,6 +241,8 @@ function ToolsetModal({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,6 +83,9 @@
|
|||||||
"resumeTriggered": "Resume request sent, the workflow is recovering...",
|
"resumeTriggered": "Resume request sent, the workflow is recovering...",
|
||||||
"resumeFailed": "Failed to resume workflow",
|
"resumeFailed": "Failed to resume workflow",
|
||||||
"sseReconnecting": "Connection lost, retrying in {{seconds}}s...",
|
"sseReconnecting": "Connection lost, retrying in {{seconds}}s...",
|
||||||
|
"fileDownload": "Download",
|
||||||
|
"fileDownloadFailed": "Download failed",
|
||||||
|
"fileForbidden": "Not allowed to download this file",
|
||||||
"workflowDetails": "Workflow Details",
|
"workflowDetails": "Workflow Details",
|
||||||
"loading": "Loading Workflows...",
|
"loading": "Loading Workflows...",
|
||||||
"titleRequired": "Please enter a workflow title",
|
"titleRequired": "Please enter a workflow title",
|
||||||
@@ -262,6 +265,8 @@
|
|||||||
"toolsetEmpty": "No toolsets available",
|
"toolsetEmpty": "No toolsets available",
|
||||||
"toolsetSystem": "System",
|
"toolsetSystem": "System",
|
||||||
"toolsetCount": "{{count}} tools",
|
"toolsetCount": "{{count}} tools",
|
||||||
|
"toolsetReadmeMissing": "No README provided for this package",
|
||||||
|
"toolsetReadmeLoading": "Loading description…",
|
||||||
"skillManagement": "Skill Management",
|
"skillManagement": "Skill Management",
|
||||||
"skillDesc": "Manage agent skills and functions",
|
"skillDesc": "Manage agent skills and functions",
|
||||||
"installSkill": "Install Skill",
|
"installSkill": "Install Skill",
|
||||||
|
|||||||
@@ -83,6 +83,9 @@
|
|||||||
"resumeTriggered": "恢复请求已发送,工作流正在恢复中...",
|
"resumeTriggered": "恢复请求已发送,工作流正在恢复中...",
|
||||||
"resumeFailed": "恢复工作流失败",
|
"resumeFailed": "恢复工作流失败",
|
||||||
"sseReconnecting": "连接断开,{{seconds}}秒后重试...",
|
"sseReconnecting": "连接断开,{{seconds}}秒后重试...",
|
||||||
|
"fileDownload": "下载附件",
|
||||||
|
"fileDownloadFailed": "下载失败",
|
||||||
|
"fileForbidden": "无权下载该文件",
|
||||||
"workflowDetails": "工作流详情",
|
"workflowDetails": "工作流详情",
|
||||||
"loading": "正在加载工作流...",
|
"loading": "正在加载工作流...",
|
||||||
"titleRequired": "请输入工作流标题",
|
"titleRequired": "请输入工作流标题",
|
||||||
@@ -262,6 +265,8 @@
|
|||||||
"toolsetEmpty": "暂无工具集",
|
"toolsetEmpty": "暂无工具集",
|
||||||
"toolsetSystem": "系统",
|
"toolsetSystem": "系统",
|
||||||
"toolsetCount": "{{count}} 个工具",
|
"toolsetCount": "{{count}} 个工具",
|
||||||
|
"toolsetReadmeMissing": "该工具包没有提供 README",
|
||||||
|
"toolsetReadmeLoading": "正在加载说明…",
|
||||||
"skillManagement": "技能管理",
|
"skillManagement": "技能管理",
|
||||||
"skillDesc": "管理代理技能和函数",
|
"skillDesc": "管理代理技能和函数",
|
||||||
"installSkill": "安装技能",
|
"installSkill": "安装技能",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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
|
from kilostar.utils.settings import get_settings
|
||||||
|
|
||||||
if not _STANDALONE:
|
if not _STANDALONE:
|
||||||
@@ -35,6 +35,7 @@ from .provider import provider_router
|
|||||||
from .resource import resource_router
|
from .resource import resource_router
|
||||||
from .workflow import workflow_router
|
from .workflow import workflow_router
|
||||||
from .chat import chat_router
|
from .chat import chat_router
|
||||||
|
from .plugin import plugin_router
|
||||||
from kilostar.utils.error import (
|
from kilostar.utils.error import (
|
||||||
KiloStarError,
|
KiloStarError,
|
||||||
BusinessError,
|
BusinessError,
|
||||||
@@ -103,6 +104,7 @@ app.include_router(resource_router) # 资源路径
|
|||||||
app.include_router(agent_router) # agent路径
|
app.include_router(agent_router) # agent路径
|
||||||
app.include_router(workflow_router) # workflow路径
|
app.include_router(workflow_router) # workflow路径
|
||||||
app.include_router(chat_router) # chat路径
|
app.include_router(chat_router) # chat路径
|
||||||
|
app.include_router(plugin_router) # plugin路径
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(BusinessError)
|
@app.exception_handler(BusinessError)
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ from typing import Union
|
|||||||
from kilostar.utils.ray_hook import ray_actor_hook
|
from kilostar.utils.ray_hook import ray_actor_hook
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from pydantic import BaseModel, field_validator
|
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 kilostar.core.postgres_database.model import AgentType
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from typing import Optional, List, Dict
|
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.core.postgres_database.model import UserAuthority
|
||||||
from kilostar.utils.mcp_helper import get_all_tools_and_toolsets_for_scope
|
from kilostar.utils.mcp_helper import get_all_tools_and_toolsets_for_scope
|
||||||
from kilostar.utils.i18n import t
|
from kilostar.utils.i18n import t
|
||||||
|
|||||||
@@ -15,10 +15,9 @@
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from pydantic import BaseModel
|
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 fastapi.concurrency import run_in_threadpool
|
||||||
from kilostar.utils.ray_hook import ray_actor_hook
|
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.core.postgres_database.model import UserAuthority
|
||||||
from kilostar.utils.error import UserNotExistError
|
from kilostar.utils.error import UserNotExistError
|
||||||
from kilostar.utils.rate_limit import register_limiter, login_limiter
|
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"])
|
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:
|
def _extract_reply(resp: MessageResponse | None) -> str | None:
|
||||||
"""从 RegulatoryNode.working 的输出里取出对用户的回复文本。
|
"""从 RegulatoryNode.working 的输出里取出对用户的回复文本。
|
||||||
@@ -39,7 +73,7 @@ def _extract_reply(resp: MessageResponse | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _ask_regulatory(
|
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:
|
) -> str | None:
|
||||||
"""统一封装 chat 入口对 RegulatoryNode 的调用。"""
|
"""统一封装 chat 入口对 RegulatoryNode 的调用。"""
|
||||||
regulatory_node = ray_actor_hook("regulatory_node").regulatory_node
|
regulatory_node = ray_actor_hook("regulatory_node").regulatory_node
|
||||||
@@ -49,7 +83,9 @@ async def _ask_regulatory(
|
|||||||
platform_id=chat_id,
|
platform_id=chat_id,
|
||||||
message=message,
|
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)
|
return _extract_reply(resp)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +156,8 @@ async def send_chat_message(
|
|||||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||||
):
|
):
|
||||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||||
# 存用户消息
|
# 先取历史(不含当前输入),再写入用户消息,避免历史里出现重复
|
||||||
|
message_history = await _load_message_history(chat_id)
|
||||||
await postgres_database.add_chat_message.remote(
|
await postgres_database.add_chat_message.remote(
|
||||||
chat_id=chat_id, message=request.message, message_owner="user"
|
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,
|
user_id=token_data.user_id,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
message=request.message,
|
message=request.message,
|
||||||
|
message_history=message_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 存回复
|
# 存回复
|
||||||
@@ -164,10 +202,12 @@ async def stream_chat_message(
|
|||||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||||
):
|
):
|
||||||
"""SSE 流式聊天端点:standalone 模式下逐 token 流式输出;distributed 模式 fallback 到整段回复。"""
|
"""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
|
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||||
|
|
||||||
|
message_history = await _load_message_history(chat_id)
|
||||||
|
|
||||||
await postgres_database.add_chat_message.remote(
|
await postgres_database.add_chat_message.remote(
|
||||||
chat_id=chat_id, message=request_body.message, message_owner="user"
|
chat_id=chat_id, message=request_body.message, message_owner="user"
|
||||||
)
|
)
|
||||||
@@ -183,7 +223,7 @@ async def stream_chat_message(
|
|||||||
|
|
||||||
if not _STANDALONE:
|
if not _STANDALONE:
|
||||||
async def fallback_generator():
|
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 ""
|
full_response = resp.reply_message if resp else ""
|
||||||
if full_response:
|
if full_response:
|
||||||
await postgres_database.add_chat_message.remote(
|
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")
|
return StreamingResponse(fallback_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
token_queue = asyncio.Queue()
|
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():
|
async def event_generator():
|
||||||
full_response = ""
|
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 fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Any, Dict, Literal
|
from typing import Any, Dict, Literal
|
||||||
from kilostar.utils.access import TokenData, Accessor
|
from kilostar.utils.access import TokenData, Accessor, RoleChecker
|
||||||
from kilostar.utils.check_user.role_check import RoleChecker
|
|
||||||
from kilostar.core.postgres_database.model import UserAuthority
|
from kilostar.core.postgres_database.model import UserAuthority
|
||||||
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
|
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
|
||||||
from kilostar.utils.ray_hook import ray_actor_hook
|
from kilostar.utils.ray_hook import ray_actor_hook
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ from pydantic import BaseModel
|
|||||||
import viceroy
|
import viceroy
|
||||||
from kilostar.utils.ray_hook import ray_actor_hook
|
from kilostar.utils.ray_hook import ray_actor_hook
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from kilostar.utils.access import TokenData
|
from fastapi.responses import FileResponse
|
||||||
from kilostar.utils.check_user.role_check import RoleChecker
|
from kilostar.utils.access import TokenData, RoleChecker, Accessor
|
||||||
from kilostar.core.postgres_database.model import UserAuthority
|
from kilostar.core.postgres_database.model import UserAuthority
|
||||||
from kilostar.utils.mcp_helper import list_mcp_tools_from_gsm
|
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")
|
resource_router = APIRouter(prefix="/api/v1/resource")
|
||||||
|
|
||||||
@@ -48,13 +49,12 @@ class MCPServerConfig(BaseModel):
|
|||||||
async def install_skill(
|
async def install_skill(
|
||||||
skill: Skill, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))
|
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
|
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||||
import os
|
import os
|
||||||
|
from kilostar.utils.settings import get_plugin_dir
|
||||||
|
|
||||||
skill_output_dir = os.path.abspath(
|
skill_output_dir = str(get_plugin_dir() / "skill")
|
||||||
os.path.join(os.path.dirname(__file__), "..", "plugin", "skill")
|
|
||||||
)
|
|
||||||
os.makedirs(skill_output_dir, exist_ok=True)
|
os.makedirs(skill_output_dir, exist_ok=True)
|
||||||
await viceroy.install_skill_async(
|
await viceroy.install_skill_async(
|
||||||
url=skill.repo_url, path=skill.path, output=skill_output_dir
|
url=skill.repo_url, path=skill.path, output=skill_output_dir
|
||||||
@@ -133,6 +133,78 @@ async def delete_mcp_server(
|
|||||||
return {"message": "success"}
|
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 ───
|
# ─── Tool Management ───
|
||||||
|
|
||||||
@resource_router.get("/tool")
|
@resource_router.get("/tool")
|
||||||
@@ -256,7 +328,7 @@ async def _assert_toolset_owner_or_admin(
|
|||||||
toolset: Dict[str, Any], token_data: TokenData
|
toolset: Dict[str, Any], token_data: TokenData
|
||||||
) -> None:
|
) -> None:
|
||||||
"""校验 toolset 归属:非 owner 且非管理员则抛 403。"""
|
"""校验 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:
|
if toolset.get("owner_id") == token_data.user_id:
|
||||||
return
|
return
|
||||||
@@ -294,7 +366,7 @@ async def list_custom_toolsets(
|
|||||||
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
|
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
|
||||||
):
|
):
|
||||||
"""列出工具组:支持按 category 过滤。USER 只能看到自己的+系统的;ADMIN 看全部。"""
|
"""列出工具组:支持按 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
|
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||||
toolsets = await global_state_machine.list_custom_toolsets.remote()
|
toolsets = await global_state_machine.list_custom_toolsets.remote()
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from kilostar.utils.ray_hook import ray_actor_hook
|
from kilostar.utils.ray_hook import ray_actor_hook
|
||||||
from kilostar.utils.access import Accessor, TokenData
|
from kilostar.utils.access import Accessor, TokenData, RoleChecker
|
||||||
from kilostar.utils.check_user.role_check import RoleChecker
|
|
||||||
from kilostar.core.postgres_database.model import UserAuthority
|
from kilostar.core.postgres_database.model import UserAuthority
|
||||||
from kilostar.utils.config_loader import (
|
from kilostar.utils.config_loader import (
|
||||||
get_workflow_config,
|
get_workflow_config,
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
import asyncio
|
import asyncio
|
||||||
from kilostar.utils.access import Accessor, TokenData
|
from kilostar.utils.access import Accessor, TokenData, RoleChecker
|
||||||
from kilostar.utils.check_user.role_check import RoleChecker
|
|
||||||
from kilostar.core.postgres_database.model import UserAuthority
|
from kilostar.core.postgres_database.model import UserAuthority
|
||||||
|
|
||||||
workflow_router = APIRouter(prefix="/api/v1/workflow", tags=["workflow"])
|
workflow_router = APIRouter(prefix="/api/v1/workflow", tags=["workflow"])
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
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:
|
if not _STANDALONE:
|
||||||
import ray
|
import ray
|
||||||
@@ -77,47 +77,40 @@ class GlobalStateMachine:
|
|||||||
# 启动期一次性发布 v1 快照,让等待中的读端立刻可用
|
# 启动期一次性发布 v1 快照,让等待中的读端立刻可用
|
||||||
self._publish_snapshot()
|
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):
|
async def _seed_system_toolsets(self):
|
||||||
"""若 DB 中缺少系统预置工具集则自动补种。"""
|
"""把磁盘上每个 toolset 包同步成一个 system custom_toolset 记录。
|
||||||
for seed in self._SYSTEM_TOOLSETS:
|
|
||||||
if seed["toolset_id"] not in self._custom_toolsets:
|
toolset 包就是插件单元——目录结构 ``data/toolset/<name>/`` 即代表一个工具集。
|
||||||
await self.postgres_database.upsert_custom_toolset.remote(
|
启动时把每个包"投影"成一条 ``is_system=True`` 的 custom_toolset,
|
||||||
toolset_id=seed["toolset_id"],
|
前端工具插件界面看到的卡片就是这些包;将来安装第三方插件 = 把目录扔进去。
|
||||||
name=seed["name"],
|
|
||||||
tools=seed["tools"],
|
旧版本写死过 ``system_basic`` / ``system_chat`` / ``system_workflow`` 这种
|
||||||
description=seed["description"],
|
逻辑分组,这里会一并清理掉,避免遗留脏数据。
|
||||||
|
"""
|
||||||
|
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,
|
owner_id=None,
|
||||||
is_system=True,
|
is_system=True,
|
||||||
category=seed["category"],
|
category=name,
|
||||||
)
|
)
|
||||||
self._custom_toolsets[seed["toolset_id"]] = seed
|
self._custom_toolsets[tid] = saved
|
||||||
|
|
||||||
# ─── Snapshot 发布(Object Store 读路径) ────────────────────
|
# ─── Snapshot 发布(Object Store 读路径) ────────────────────
|
||||||
|
|
||||||
@@ -254,6 +247,24 @@ class GlobalStateMachine:
|
|||||||
"""仅返回 retrieval 工具集(system_node 专用,不包含 generation 工具)。"""
|
"""仅返回 retrieval 工具集(system_node 专用,不包含 generation 工具)。"""
|
||||||
return self._global_tool_manager.get_retrieval_toolsets_for_scope(scope)
|
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 ───────────────────────────────────
|
# ─── MCP Server Registry ───────────────────────────────────
|
||||||
|
|
||||||
async def add_mcp_server(self, server_id: str, config: Dict[str, Any]) -> bool:
|
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 dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
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:
|
if not _STANDALONE:
|
||||||
import ray
|
import ray
|
||||||
@@ -63,7 +63,7 @@ class GSMSnapshot:
|
|||||||
tool_metadata: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
tool_metadata: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||||
tool_funcs: Dict[str, Callable[..., 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)
|
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 维护的工具名清单。
|
# ``{scope: [tool_name, ...]}``:系统工具按 scope 维护的工具名清单。
|
||||||
# 客户端按名字 + ``tool_funcs`` 在自己进程里重建 FunctionToolset,
|
# 客户端按名字 + ``tool_funcs`` 在自己进程里重建 FunctionToolset,
|
||||||
# 避开把不可序列化/版本耦合的 toolset 实例塞进快照的坑。
|
# 避开把不可序列化/版本耦合的 toolset 实例塞进快照的坑。
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ from collections import defaultdict
|
|||||||
import pathlib
|
import pathlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from kilostar.utils.settings import get_plugin_dir
|
||||||
|
|
||||||
|
|
||||||
class GlobalSkillManager:
|
class GlobalSkillManager:
|
||||||
"""Skill 注册表:从 ``kilostar/plugin/skill/<name>/skill.json`` 启动期一次性扫描加载。"""
|
"""Skill 注册表:从 ``data/plugin/skill/<name>/skill.json`` 启动期一次性扫描加载。"""
|
||||||
|
|
||||||
skill_mapper = Dict[str, Tuple[str]]
|
skill_mapper = Dict[str, Tuple[str]]
|
||||||
"""skill的存储表"""
|
"""skill的存储表"""
|
||||||
@@ -27,23 +29,16 @@ class GlobalSkillManager:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.skill_mapper = defaultdict(tuple)
|
self.skill_mapper = defaultdict(tuple)
|
||||||
|
|
||||||
import os
|
skill_plugin_dir = get_plugin_dir() / "skill"
|
||||||
|
|
||||||
skill_plugin_dir = pathlib.Path(
|
|
||||||
os.path.abspath(
|
|
||||||
os.path.join(os.path.dirname(__file__), "..", "..", "plugin", "skill")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not skill_plugin_dir.exists() or not skill_plugin_dir.is_dir():
|
if not skill_plugin_dir.exists() or not skill_plugin_dir.is_dir():
|
||||||
return
|
return
|
||||||
for item in skill_plugin_dir.iterdir():
|
for item in skill_plugin_dir.iterdir():
|
||||||
if item.is_dir() and not item.name.startswith((".", "__")):
|
if item.is_dir() and not item.name.startswith((".", "__")):
|
||||||
json_path = item / "skill.json" # 拼接文件路径
|
json_path = item / "skill.json"
|
||||||
if json_path.exists():
|
if json_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(json_path, "r", encoding="utf-8") as f:
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
skill = json.load(f)
|
skill = json.load(f)
|
||||||
# 提取并映射
|
|
||||||
name = skill.get("name")
|
name = skill.get("name")
|
||||||
if name:
|
if name:
|
||||||
self.skill_mapper[name] = (
|
self.skill_mapper[name] = (
|
||||||
@@ -55,13 +50,7 @@ class GlobalSkillManager:
|
|||||||
|
|
||||||
def add_skill(self, skill_name: str) -> None:
|
def add_skill(self, skill_name: str) -> None:
|
||||||
"""Add a skill to the manager by reading its skill.json from the path"""
|
"""Add a skill to the manager by reading its skill.json from the path"""
|
||||||
import os
|
skill_plugin_dir = get_plugin_dir() / "skill"
|
||||||
|
|
||||||
skill_plugin_dir = pathlib.Path(
|
|
||||||
os.path.abspath(
|
|
||||||
os.path.join(os.path.dirname(__file__), "..", "..", "plugin", "skill")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
item = skill_plugin_dir / skill_name
|
item = skill_plugin_dir / skill_name
|
||||||
if item.is_dir() and not item.name.startswith((".", "__")):
|
if item.is_dir() and not item.name.startswith((".", "__")):
|
||||||
json_path = item / "skill.json"
|
json_path = item / "skill.json"
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import pathlib
|
import json
|
||||||
import importlib
|
import importlib.util
|
||||||
import inspect
|
import sys
|
||||||
|
import types
|
||||||
from collections import defaultdict
|
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
|
from kilostar.utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger("tool_manager")
|
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:
|
class GlobalToolManager:
|
||||||
"""工具注册表:扫描 ``kilostar/plugin/tool_plugin/`` 下所有 BaseToolData 子类,
|
"""工具注册表:扫描 ``data/toolset/`` 下所有 toolset 的 manifest.json,
|
||||||
按 ``action_scope`` 打包成 ``FunctionToolset``。
|
按 ``action_scope`` / ``is_system`` / ``category`` 分桶。
|
||||||
|
|
||||||
三类 toolset:
|
三类 toolset:
|
||||||
- **system**:``is_system=True`` 的工具,按 scope 分组
|
- **system**:``is_system=True`` 的工具,按 scope 分组
|
||||||
- **custom**:用户自定义工具组(由 ``rebuild_custom_toolsets`` 动态构建)
|
- **custom**:用户自定义工具组(由 ``rebuild_custom_toolsets`` 动态构建)
|
||||||
- **mcp**:由 ``mcp_helper`` 独立管理,不经过本类
|
- **mcp**:由 ``mcp_helper`` 独立管理,不经过本类
|
||||||
|
|
||||||
``category="mcp"`` 的工具不会被本类管理。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tool_metadata: Dict[str, Dict[str, Any]]
|
tool_metadata: Dict[str, Dict[str, Any]]
|
||||||
@@ -30,7 +42,7 @@ class GlobalToolManager:
|
|||||||
_custom_toolsets: Dict[str, Any]
|
_custom_toolsets: Dict[str, Any]
|
||||||
_third_party_funcs: Dict[str, Callable]
|
_third_party_funcs: Dict[str, Callable]
|
||||||
_all_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:
|
def __init__(self) -> None:
|
||||||
self.tool_metadata = {}
|
self.tool_metadata = {}
|
||||||
@@ -41,75 +53,126 @@ class GlobalToolManager:
|
|||||||
self._custom_toolsets = {}
|
self._custom_toolsets = {}
|
||||||
self._third_party_funcs = {}
|
self._third_party_funcs = {}
|
||||||
self._all_funcs = {}
|
self._all_funcs = {}
|
||||||
self.tool_mapper = defaultdict(dict)
|
self.toolset_packages = {}
|
||||||
|
|
||||||
tool_plugin_dir = (
|
_bootstrap_toolset_modules()
|
||||||
pathlib.Path(__file__).parent.parent.parent / "plugin" / "tool_plugin"
|
|
||||||
)
|
toolset_root = get_toolset_dir()
|
||||||
if not tool_plugin_dir.exists() or not tool_plugin_dir.is_dir():
|
if not toolset_root.exists() or not toolset_root.is_dir():
|
||||||
return
|
return
|
||||||
|
|
||||||
for item in tool_plugin_dir.iterdir():
|
for toolset_dir in toolset_root.iterdir():
|
||||||
if not (item.is_dir() and not item.name.startswith("__")):
|
if not toolset_dir.is_dir() or toolset_dir.name.startswith("__"):
|
||||||
continue
|
continue
|
||||||
plugin_name = item.name
|
manifest_path = toolset_dir / "manifest.json"
|
||||||
module_name = f"kilostar.plugin.tool_plugin.{plugin_name}"
|
if not manifest_path.exists():
|
||||||
|
continue
|
||||||
|
self._load_toolset(toolset_dir, manifest_path)
|
||||||
|
|
||||||
|
self._build_system_toolsets()
|
||||||
|
self._build_retrieval_toolsets()
|
||||||
|
|
||||||
|
def _load_toolset(self, toolset_dir, manifest_path) -> None:
|
||||||
|
"""从一个 toolset 目录加载 manifest 并注册所有工具。"""
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(module_name)
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to import tool plugin {plugin_name}: {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
|
continue
|
||||||
|
|
||||||
tool_data_cls = self._find_tool_data_class(module)
|
file_path = toolset_dir / tool_file
|
||||||
if tool_data_cls is None:
|
if not file_path.exists():
|
||||||
|
logger.warning(f"Tool file not found: {file_path}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tool_func = getattr(module, plugin_name, None)
|
tool_func = self._load_tool_func(toolset_name, tool_name, file_path)
|
||||||
if not callable(tool_func):
|
if tool_func is None:
|
||||||
logger.warning(
|
|
||||||
f"Tool plugin '{plugin_name}' has no callable named "
|
|
||||||
f"'{plugin_name}' in its module; skipped."
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
action_scopes = (
|
is_system = tool_def.get("is_system", True)
|
||||||
tool_data_cls.model_fields.get("action_scope").default or []
|
action_scopes = tool_def.get("action_scope", [])
|
||||||
)
|
category = tool_def.get("category", "other")
|
||||||
is_system = bool(tool_data_cls.model_fields.get("is_system").default)
|
config_args = tool_def.get("config_args", {})
|
||||||
category_field = tool_data_cls.model_fields.get("category")
|
toolset_field = tool_def.get("toolset", "other")
|
||||||
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"
|
|
||||||
|
|
||||||
self.tool_metadata[plugin_name] = {
|
self.tool_metadata[tool_name] = {
|
||||||
"name": plugin_name,
|
"name": tool_name,
|
||||||
"is_system": is_system,
|
"is_system": is_system,
|
||||||
"category": category,
|
"category": category,
|
||||||
"toolset": toolset_name,
|
"toolset": toolset_field,
|
||||||
"action_scope": list(action_scopes),
|
"action_scope": list(action_scopes),
|
||||||
|
"config_args": config_args,
|
||||||
|
"source_toolset": toolset_name,
|
||||||
}
|
}
|
||||||
|
registered_tools.append(tool_name)
|
||||||
|
|
||||||
if category == "mcp":
|
if category == "mcp":
|
||||||
continue
|
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"]
|
scopes = [s for s in action_scopes if s] or ["default"]
|
||||||
|
|
||||||
if is_system:
|
if is_system:
|
||||||
for scope in scopes:
|
for scope in scopes:
|
||||||
self._tool_funcs[scope][plugin_name] = tool_func
|
self._tool_funcs[scope][tool_name] = tool_func
|
||||||
self.tool_mapper[scope][plugin_name] = tool_data_cls
|
if toolset_field == "retrieval":
|
||||||
if toolset_name == "retrieval":
|
self._retrieval_tool_funcs[scope][tool_name] = tool_func
|
||||||
self._retrieval_tool_funcs[scope][plugin_name] = tool_func
|
|
||||||
else:
|
else:
|
||||||
self._third_party_funcs[plugin_name] = tool_func
|
self._third_party_funcs[tool_name] = tool_func
|
||||||
for scope in scopes:
|
|
||||||
self.tool_mapper[scope][plugin_name] = tool_data_cls
|
|
||||||
|
|
||||||
self._build_system_toolsets()
|
readme_path = toolset_dir / "README.md"
|
||||||
self._build_retrieval_toolsets()
|
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:
|
def _build_system_toolsets(self) -> None:
|
||||||
FunctionToolset = self._import_function_toolset()
|
FunctionToolset = self._import_function_toolset()
|
||||||
@@ -142,11 +205,7 @@ class GlobalToolManager:
|
|||||||
logger.error(f"Failed to build retrieval toolset {scope}: {e}")
|
logger.error(f"Failed to build retrieval toolset {scope}: {e}")
|
||||||
|
|
||||||
def rebuild_custom_toolsets(self, custom_defs: Dict[str, Dict[str, Any]]) -> None:
|
def rebuild_custom_toolsets(self, custom_defs: Dict[str, Dict[str, Any]]) -> None:
|
||||||
"""根据 DB 中的 toolset 定义重建 FunctionToolset。
|
"""根据 DB 中的 toolset 定义重建 FunctionToolset。"""
|
||||||
|
|
||||||
系统 toolset(is_system=True)允许包含 system 工具,用户 toolset 只取得到 callable
|
|
||||||
的工具(理论上业务层已校验只包含第三方工具)。
|
|
||||||
"""
|
|
||||||
FunctionToolset = self._import_function_toolset()
|
FunctionToolset = self._import_function_toolset()
|
||||||
if FunctionToolset is None:
|
if FunctionToolset is None:
|
||||||
self._custom_toolsets = {}
|
self._custom_toolsets = {}
|
||||||
@@ -178,15 +237,13 @@ class GlobalToolManager:
|
|||||||
logger.warning("pydantic_ai.toolsets unavailable")
|
logger.warning("pydantic_ai.toolsets unavailable")
|
||||||
return None
|
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 ───
|
# ─── 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:
|
def get_system_toolset(self, scope: str) -> Any | None:
|
||||||
return self._system_toolsets.get(scope)
|
return self._system_toolsets.get(scope)
|
||||||
|
|
||||||
@@ -230,7 +287,6 @@ class GlobalToolManager:
|
|||||||
def get_all_tools(self) -> List[Dict[str, Any]]:
|
def get_all_tools(self) -> List[Dict[str, Any]]:
|
||||||
return list(self.tool_metadata.values())
|
return list(self.tool_metadata.values())
|
||||||
|
|
||||||
# 兼容旧接口
|
|
||||||
def get_non_system_tools(self) -> List[Dict[str, Any]]:
|
def get_non_system_tools(self) -> List[Dict[str, Any]]:
|
||||||
return self.get_third_party_tools()
|
return self.get_third_party_tools()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Dict
|
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.ray_hook import ray_actor_hook
|
||||||
from kilostar.utils.logger import get_logger
|
from kilostar.utils.logger import get_logger
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
|
|
||||||
from typing import Union, overload
|
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 (
|
from kilostar.core.individual.consciousness_node.template import (
|
||||||
ConsciousnessNodeDeps,
|
ConsciousnessNodeDeps,
|
||||||
ForregulatoryNode,
|
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.core.global_state_machine.model_provider.base_provider import Provider
|
||||||
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
||||||
from kilostar.utils.ray_hook import ray_actor_hook
|
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
|
@actor_class
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from pydantic_ai import Agent, RunContext
|
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.global_state_machine import GlobalStateMachine
|
||||||
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
|
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
|
||||||
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
||||||
@@ -22,7 +22,7 @@ from kilostar.core.individual.control_node.template import (
|
|||||||
ForWorkflowInput,
|
ForWorkflowInput,
|
||||||
ControlNodeDeps,
|
ControlNodeDeps,
|
||||||
)
|
)
|
||||||
from kilostar.utils.i18n import agent_prompt
|
from kilostar.utils.prompts import agent_prompt
|
||||||
|
|
||||||
|
|
||||||
@actor_class
|
@actor_class
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Union
|
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.adapter.model_adapter.agent_factory import AgentFactory
|
||||||
from kilostar.core.global_state_machine.global_state_machine import GlobalStateMachine
|
from kilostar.core.global_state_machine.global_state_machine import GlobalStateMachine
|
||||||
from kilostar.core.global_state_machine.model_provider import Provider
|
from kilostar.core.global_state_machine.model_provider import Provider
|
||||||
@@ -25,7 +25,7 @@ from kilostar.core.individual.regulatory_node.template import (
|
|||||||
MessageResponse
|
MessageResponse
|
||||||
)
|
)
|
||||||
from pydantic_ai import RunContext, Agent
|
from pydantic_ai import RunContext, Agent
|
||||||
from kilostar.utils.i18n import agent_prompt
|
from kilostar.utils.prompts import agent_prompt
|
||||||
|
|
||||||
|
|
||||||
@actor_class
|
@actor_class
|
||||||
@@ -111,15 +111,20 @@ class RegulatoryNode:
|
|||||||
)
|
)
|
||||||
return prompt
|
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函数的结果进行判断并返回最终回复
|
"""working方法,是节点唯一的调用方法,对_run函数的结果进行判断并返回最终回复
|
||||||
Args:
|
Args:
|
||||||
payload: 消息载荷,包含所有信息
|
payload: 消息载荷,包含所有信息
|
||||||
|
message_history: pydantic-ai ``ModelMessage`` 列表,传入历史让多轮对话连贯
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
MessageResponse 或 None,监控节点对用户的结构化回复
|
MessageResponse 或 None,监控节点对用户的结构化回复
|
||||||
"""
|
"""
|
||||||
return await self._run(payload)
|
return await self._run(payload, message_history=message_history)
|
||||||
|
|
||||||
_CHAT_INSTRUCTIONS = (
|
_CHAT_INSTRUCTIONS = (
|
||||||
"你是 kilostar 智能助手。你现在处于【直接对话模式】,请直接回答用户的问题。\n"
|
"你是 kilostar 智能助手。你现在处于【直接对话模式】,请直接回答用户的问题。\n"
|
||||||
@@ -130,7 +135,12 @@ class RegulatoryNode:
|
|||||||
"4. 回复应当完整、有帮助,避免过于简短。\n"
|
"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。
|
"""流式对话:完整执行 agent graph(含工具调用),逐 token 推送文本到 queue。
|
||||||
|
|
||||||
使用 event_stream_handler 回调拿到每个 text delta,保证工具调用后
|
使用 event_stream_handler 回调拿到每个 text delta,保证工具调用后
|
||||||
@@ -167,6 +177,7 @@ class RegulatoryNode:
|
|||||||
output_type=str,
|
output_type=str,
|
||||||
instructions=self._CHAT_INSTRUCTIONS,
|
instructions=self._CHAT_INSTRUCTIONS,
|
||||||
event_stream_handler=_stream_handler,
|
event_stream_handler=_stream_handler,
|
||||||
|
message_history=message_history,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(f"RegulatoryNode.stream_working failed: {e}")
|
self.logger.exception(f"RegulatoryNode.stream_working failed: {e}")
|
||||||
@@ -175,7 +186,9 @@ class RegulatoryNode:
|
|||||||
await token_queue.put(None)
|
await token_queue.put(None)
|
||||||
|
|
||||||
async def _run(
|
async def _run(
|
||||||
self, payload: MessageRequest
|
self,
|
||||||
|
payload: MessageRequest,
|
||||||
|
message_history: list | None = None,
|
||||||
) -> Union[MessageResponse, None]:
|
) -> Union[MessageResponse, None]:
|
||||||
platform = payload.platform
|
platform = payload.platform
|
||||||
user_name = payload.user_name
|
user_name = payload.user_name
|
||||||
@@ -187,8 +200,11 @@ class RegulatoryNode:
|
|||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
time=time_str
|
time=time_str
|
||||||
)
|
)
|
||||||
agent_response = await self.agent.run(user_prompt=message,
|
agent_response = await self.agent.run(
|
||||||
deps=deps,)
|
user_prompt=message,
|
||||||
|
deps=deps,
|
||||||
|
message_history=message_history,
|
||||||
|
)
|
||||||
response: MessageResponse = agent_response.output
|
response: MessageResponse = agent_response.output
|
||||||
response.platform = platform
|
response.platform = platform
|
||||||
response.platform_id = payload.platform_id
|
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.custom_toolset import CustomToolsetModel
|
||||||
from kilostar.core.postgres_database.model.system_event_log import SystemEventLog
|
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.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
|
Provider = ProviderModel
|
||||||
@@ -65,5 +67,7 @@ __all__ = [
|
|||||||
"CustomToolsetModel",
|
"CustomToolsetModel",
|
||||||
"SystemEventLog",
|
"SystemEventLog",
|
||||||
"PersonaTemplate",
|
"PersonaTemplate",
|
||||||
|
"OrgTask",
|
||||||
|
"OrgTaskEvent",
|
||||||
"AgentType",
|
"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 os
|
||||||
import asyncio
|
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 kilostar.utils.settings import get_settings
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
from sqlalchemy.orm import sessionmaker
|
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.custom_toolset import CustomToolsetModel
|
||||||
from kilostar.core.postgres_database.model.system_event_log import SystemEventLog
|
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.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.individual import IndividualDatabase
|
||||||
from .module.user import AuthDatabase
|
from .module.user import AuthDatabase
|
||||||
@@ -56,6 +58,7 @@ from .module.tool_config import ToolConfigDatabase
|
|||||||
from .module.custom_toolset import CustomToolsetDatabase
|
from .module.custom_toolset import CustomToolsetDatabase
|
||||||
from .module.system_event_log import SystemEventLogDatabase
|
from .module.system_event_log import SystemEventLogDatabase
|
||||||
from .module.persona_template import PersonaTemplateDatabase
|
from .module.persona_template import PersonaTemplateDatabase
|
||||||
|
from .module.org_task import OrgTaskDatabase
|
||||||
|
|
||||||
|
|
||||||
@actor_class
|
@actor_class
|
||||||
@@ -89,6 +92,7 @@ class PostgresDatabase:
|
|||||||
self._custom_toolset_database = CustomToolsetDatabase(self.async_session_maker)
|
self._custom_toolset_database = CustomToolsetDatabase(self.async_session_maker)
|
||||||
self._system_event_log_database = SystemEventLogDatabase(self.async_session_maker)
|
self._system_event_log_database = SystemEventLogDatabase(self.async_session_maker)
|
||||||
self._persona_template_database = PersonaTemplateDatabase(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()
|
self.ready_event = asyncio.Event()
|
||||||
|
|
||||||
@@ -458,3 +462,28 @@ class PostgresDatabase:
|
|||||||
async def delete_template(self, template_id: str):
|
async def delete_template(self, template_id: str):
|
||||||
await self.ready_event.wait()
|
await self.ready_event.wait()
|
||||||
return await self._persona_template_database.delete_template(template_id)
|
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 dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
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 import BaseModel, Field
|
||||||
from pydantic_graph import BaseNode, End, Graph, GraphRunContext
|
from pydantic_graph import BaseNode, End, Graph, GraphRunContext
|
||||||
from pydantic_graph.persistence import BaseStatePersistence
|
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
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Annotated, Optional
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
from pwdlib import PasswordHash
|
from pwdlib import PasswordHash
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from kilostar.core.postgres_database.model import UserAuthority
|
||||||
from kilostar.core.postgres_database.model.user import User
|
from kilostar.core.postgres_database.model.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -174,3 +175,61 @@ class Accessor:
|
|||||||
if not (has_alpha and has_digit):
|
if not (has_alpha and has_digit):
|
||||||
raise ValueError("密码必须同时包含字母和数字")
|
raise ValueError("密码必须同时包含字母和数字")
|
||||||
return password_hasher.hash(password)
|
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.console import Console
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
import yaml
|
|
||||||
|
from kilostar.utils.config_loader import get_app_config
|
||||||
|
|
||||||
|
|
||||||
def print_banner() -> None:
|
def print_banner() -> None:
|
||||||
"""在启动阶段输出 KiloStar 的 ASCII 横幅与版本/作者元信息。"""
|
"""在启动阶段输出 KiloStar 的 ASCII 横幅与版本/作者元信息。"""
|
||||||
with open("config/config.yml", "r") as config:
|
version = get_app_config().app.version
|
||||||
config = yaml.load(config, Loader=yaml.FullLoader)
|
|
||||||
version = config.get("version", "unknown")
|
|
||||||
kilostar_banner = r"""
|
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 importlib.util
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
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.logger import get_logger
|
||||||
|
from kilostar.utils.settings import get_toolset_dir
|
||||||
|
|
||||||
logger = get_logger("get_tool")
|
logger = get_logger("get_tool")
|
||||||
_tool_cache: Dict[str, Callable] = {}
|
_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:
|
def _get_tool_func(tool_name: str) -> Callable | None:
|
||||||
"""按名字从 ``kilostar/plugin/tool_plugin/<tool_name>/__init__.py`` 中加载工具函数。
|
"""按名字从 toolset 中加载工具函数。
|
||||||
|
|
||||||
加载成功后会被缓存到模块级 ``_tool_cache``;找不到目录、找不到同名函数或
|
根据 manifest 找到工具所在的 toolset 和文件,动态加载模块并取出同名函数。
|
||||||
导入失败都会记录日志并返回 ``None``。
|
|
||||||
"""
|
"""
|
||||||
func = _tool_cache.get(tool_name, None)
|
func = _tool_cache.get(tool_name)
|
||||||
if func:
|
if func:
|
||||||
return func
|
return func
|
||||||
|
|
||||||
tool_plugin_dir = os.path.join(
|
manifests = _load_manifests()
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
info = manifests.get(tool_name)
|
||||||
"plugin",
|
if not info:
|
||||||
"tool_plugin",
|
logger.error(f"Tool '{tool_name}' not found in any toolset manifest")
|
||||||
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}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
init_file = os.path.join(tool_plugin_dir, "__init__.py")
|
tool_file = os.path.join(info["toolset_dir"], info["file"])
|
||||||
if not os.path.exists(init_file):
|
if not os.path.exists(tool_file):
|
||||||
logger.error(f"Tool init file not found: {init_file}")
|
logger.error(f"Tool file not found: {tool_file}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module_name = f"kilostar.plugin.tool_plugin.{tool_name}"
|
module_name = f"data.toolset.{info['toolset_name']}.{tool_name}"
|
||||||
spec = importlib.util.spec_from_file_location(module_name, init_file)
|
spec = importlib.util.spec_from_file_location(module_name, tool_file)
|
||||||
if spec is None or spec.loader is None:
|
if spec is None or spec.loader is None:
|
||||||
logger.error(f"Failed to create spec for {module_name}")
|
logger.error(f"Failed to create spec for {module_name}")
|
||||||
return None
|
return None
|
||||||
@@ -70,7 +87,7 @@ def _get_tool_func(tool_name: str) -> Callable | None:
|
|||||||
_tool_cache[tool_name] = func
|
_tool_cache[tool_name] = func
|
||||||
return func
|
return func
|
||||||
except Exception as e:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +97,12 @@ def del_tool_cache(tool_name: str) -> None:
|
|||||||
del _tool_cache[tool_name]
|
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]:
|
def load_tools_from_list(tool_names: List[str] | None) -> List[Callable]:
|
||||||
"""批量加载工具:传入工具名列表,返回成功加载到的函数对象列表(失败项被跳过)。"""
|
"""批量加载工具:传入工具名列表,返回成功加载到的函数对象列表(失败项被跳过)。"""
|
||||||
if not tool_names:
|
if not tool_names:
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
设计原则:
|
设计原则:
|
||||||
- 纯内存字典,无文件 IO,Ray 远程序列化零成本。
|
- 纯内存字典,无文件 IO,Ray 远程序列化零成本。
|
||||||
- 支持环境变量 ``KILOSTAR_LANG`` 作为全局默认语言。
|
- 支持环境变量 ``KILOSTAR_LANG`` 作为全局默认语言。
|
||||||
- Agent system prompt 按 ``{locale}`` 分桶,调用方显式传入 locale。
|
|
||||||
- API 层通过请求头 ``Accept-Language`` 解析首选语言。
|
- API 层通过请求头 ``Accept-Language`` 解析首选语言。
|
||||||
|
|
||||||
当前支持:``zh`` (简体中文), ``en`` (English)。
|
当前支持:``zh`` (简体中文), ``en`` (English)。
|
||||||
@@ -31,93 +30,6 @@ from kilostar.utils.settings import get_settings
|
|||||||
|
|
||||||
_DEFAULT_LOCALE: str = get_settings().kilostar_lang
|
_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 / 通用消息 ────────────────────────────────────────────────────────
|
# ─── API / 通用消息 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_MESSAGES: Dict[str, Dict[str, str]] = {
|
_MESSAGES: Dict[str, Dict[str, str]] = {
|
||||||
@@ -158,7 +70,6 @@ def _resolve_locale(locale: str | None = None, accept_language: str | None = Non
|
|||||||
if locale:
|
if locale:
|
||||||
return locale if locale in ("zh", "en") else _DEFAULT_LOCALE
|
return locale if locale in ("zh", "en") else _DEFAULT_LOCALE
|
||||||
if accept_language:
|
if accept_language:
|
||||||
# 简单解析:取第一个 segment,若含 zh 则 zh,含 en 则 en
|
|
||||||
first = accept_language.split(",")[0].split(";")[0].strip().lower()
|
first = accept_language.split(",")[0].split(";")[0].strip().lower()
|
||||||
if "zh" in first:
|
if "zh" in first:
|
||||||
return "zh"
|
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)
|
loc = _resolve_locale(locale, accept_language)
|
||||||
text = _MESSAGES.get(loc, {}).get(key) or _MESSAGES.get(_DEFAULT_LOCALE, {}).get(key) or key
|
text = _MESSAGES.get(loc, {}).get(key) or _MESSAGES.get(_DEFAULT_LOCALE, {}).get(key) or key
|
||||||
return text.format(**kwargs) if kwargs else text
|
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
|
单机模式下,所有 Actor 退化为普通 Python 异步单例,通过 StandaloneProxy
|
||||||
包装后暴露与 Ray Actor Handle 相同的 `.method.remote(args)` 调用接口,
|
包装后暴露与 Ray Actor Handle 相同的 `.method.remote(args)` 调用接口,
|
||||||
@@ -9,10 +9,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any, Type, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
_STANDALONE = os.environ.get("KILOSTAR_MODE", "distributed") == "standalone"
|
_STANDALONE = os.environ.get("KILOSTAR_MODE", "distributed") == "standalone"
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=Type[BaseModel])
|
||||||
|
|
||||||
|
|
||||||
class _MethodProxy:
|
class _MethodProxy:
|
||||||
"""包装单个方法,使 .remote(*args, **kwargs) 返回一个可 await 的 Task。"""
|
"""包装单个方法,使 .remote(*args, **kwargs) 返回一个可 await 的 Task。"""
|
||||||
@@ -84,3 +88,19 @@ def remote_task(func):
|
|||||||
|
|
||||||
import ray
|
import ray
|
||||||
return ray.remote(func)
|
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 functools import lru_cache
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from kilostar.utils.standalone_proxy import _STANDALONE
|
from kilostar.utils.ray_compat import _STANDALONE
|
||||||
|
|
||||||
if not _STANDALONE:
|
if not _STANDALONE:
|
||||||
import ray
|
import ray
|
||||||
@@ -49,7 +49,7 @@ _standalone_registry: Dict[str, Any] = {}
|
|||||||
|
|
||||||
def register_standalone(name: str, instance: Any) -> None:
|
def register_standalone(name: str, instance: Any) -> None:
|
||||||
"""注册一个单机模式下的 Actor 单例(已包装为 StandaloneProxy)。"""
|
"""注册一个单机模式下的 Actor 单例(已包装为 StandaloneProxy)。"""
|
||||||
from kilostar.utils.standalone_proxy import StandaloneProxy
|
from kilostar.utils.ray_compat import StandaloneProxy
|
||||||
|
|
||||||
_standalone_registry[name] = StandaloneProxy(instance)
|
_standalone_registry[name] = StandaloneProxy(instance)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class AppSettings(BaseSettings):
|
|||||||
kilostar_mode: str = "distributed"
|
kilostar_mode: str = "distributed"
|
||||||
kilostar_lang: str = "zh"
|
kilostar_lang: str = "zh"
|
||||||
kilostar_cors_origins: str = ""
|
kilostar_cors_origins: str = ""
|
||||||
|
kilostar_plugin_dir: str = ""
|
||||||
|
kilostar_toolset_dir: str = ""
|
||||||
|
kilostar_artifact_dir: str = ""
|
||||||
|
|
||||||
db: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
db: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||||
security: SecuritySettings = Field(default_factory=SecuritySettings)
|
security: SecuritySettings = Field(default_factory=SecuritySettings)
|
||||||
@@ -53,3 +56,46 @@ class AppSettings(BaseSettings):
|
|||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> AppSettings:
|
def get_settings() -> AppSettings:
|
||||||
return 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 time
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import OrderedDict
|
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
|
from kilostar.utils.ray_hook import ray_actor_hook
|
||||||
|
|
||||||
if _STANDALONE:
|
if _STANDALONE:
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ class SkillIndividual(BaseIndividual):
|
|||||||
|
|
||||||
async def _load_skill_tools(self):
|
async def _load_skill_tools(self):
|
||||||
"""动态加载已绑定的 skill 工具。"""
|
"""动态加载已绑定的 skill 工具。"""
|
||||||
|
from kilostar.utils.settings import get_plugin_dir
|
||||||
|
|
||||||
tools = []
|
tools = []
|
||||||
bound_skill = self.agent_config.get("bound_skill", "")
|
bound_skill = self.agent_config.get("bound_skill", "")
|
||||||
# bound_skill can be string or dict {"skill_name": ["file1", "file2"]}
|
|
||||||
skill_mapper = {}
|
skill_mapper = {}
|
||||||
if isinstance(bound_skill, str) and bound_skill:
|
if isinstance(bound_skill, str) and bound_skill:
|
||||||
try:
|
try:
|
||||||
@@ -47,9 +48,7 @@ class SkillIndividual(BaseIndividual):
|
|||||||
elif isinstance(bound_skill, dict):
|
elif isinstance(bound_skill, dict):
|
||||||
skill_mapper = bound_skill
|
skill_mapper = bound_skill
|
||||||
|
|
||||||
skill_base_dir = os.path.abspath(
|
skill_base_dir = str(get_plugin_dir() / "skill")
|
||||||
os.path.join(os.path.dirname(__file__), "..", "plugin", "skill")
|
|
||||||
)
|
|
||||||
|
|
||||||
for skill_name, _ in skill_mapper.items():
|
for skill_name, _ in skill_mapper.items():
|
||||||
skill_path = os.path.join(skill_base_dir, skill_name)
|
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.global_workflow_manager import GlobalWorkflowManager
|
||||||
from kilostar.core.individual.regulatory_node import RegulatoryNode
|
from kilostar.core.individual.regulatory_node import RegulatoryNode
|
||||||
from kilostar.core.individual.consciousness_node import ConsciousnessNode
|
from kilostar.core.individual.consciousness_node import ConsciousnessNode
|
||||||
|
from kilostar.plugin_runtime.plugin_manager import GlobalPluginManager
|
||||||
|
|
||||||
if KILOSTAR_MODE != "standalone":
|
if KILOSTAR_MODE != "standalone":
|
||||||
import ray
|
import ray
|
||||||
@@ -58,7 +59,7 @@ async def start_standalone():
|
|||||||
await postgres_database.init_db()
|
await postgres_database.init_db()
|
||||||
register_standalone("postgres_database", postgres_database)
|
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)
|
postgres_proxy = StandaloneProxy(postgres_database)
|
||||||
|
|
||||||
global_state_machine = GlobalStateMachine(postgres_proxy)
|
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_core", worker_cluster)
|
||||||
register_standalone("worker_cluster_gpu", 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")
|
print(f"✅ KiloStar 单机模式启动完成,监听 0.0.0.0:8000")
|
||||||
|
|
||||||
config = uvicorn.Config(app, host="0.0.0.0", port=8000, log_level="info")
|
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")
|
print(f"\n[致命错误] GlobalWorkflowManager 启动失败!\n{e}\n")
|
||||||
return
|
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.start(http_options={"host": "0.0.0.0", "port": 8000})
|
||||||
serve.run(KiloStarGateway.bind())
|
serve.run(KiloStarGateway.bind())
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ def _tpl(owner: str = "alice", is_builtin: bool = False):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(monkeypatch):
|
def app(monkeypatch):
|
||||||
import kilostar.utils.check_user.role_check as rc
|
import kilostar.utils.access as access_mod
|
||||||
monkeypatch.setattr(rc, "get_authority", AsyncMock(return_value=UserAuthority.USER))
|
monkeypatch.setattr(access_mod, "get_authority", AsyncMock(return_value=UserAuthority.USER))
|
||||||
_app = FastAPI()
|
_app = FastAPI()
|
||||||
_app.include_router(agent_router)
|
_app.include_router(agent_router)
|
||||||
_app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user()
|
_app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user()
|
||||||
|
|||||||
@@ -21,17 +21,16 @@ def _fake_user(user_id: str = "alice"):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app_with_user(monkeypatch):
|
def app_with_user(monkeypatch):
|
||||||
"""挂上 resource_router;用 dependency_overrides 跳过 JWT,并把 get_authority 默认放成 USER。"""
|
"""挂上 resource_router;用 dependency_overrides 跳过 JWT,并把 get_authority 默认放成 USER。"""
|
||||||
|
import kilostar.utils.access as access_mod
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(resource_router)
|
app.include_router(resource_router)
|
||||||
app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user("alice")
|
app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user("alice")
|
||||||
|
|
||||||
# 默认把权限置为 USER;具体 case 内部可再 monkeypatch 覆盖
|
|
||||||
async def _default_authority(uid):
|
async def _default_authority(uid):
|
||||||
return UserAuthority.USER
|
return UserAuthority.USER
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(access_mod, "get_authority", _default_authority)
|
||||||
"kilostar.utils.check_user.role_check.get_authority", _default_authority
|
|
||||||
)
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -87,9 +86,8 @@ async def test_get_custom_toolset_allowed_for_admin(
|
|||||||
async def _admin(uid):
|
async def _admin(uid):
|
||||||
return UserAuthority.SUPER_ADMINISTRATOR
|
return UserAuthority.SUPER_ADMINISTRATOR
|
||||||
|
|
||||||
monkeypatch.setattr(
|
import kilostar.utils.access as access_mod
|
||||||
"kilostar.utils.check_user.role_check.get_authority", _admin
|
monkeypatch.setattr(access_mod, "get_authority", _admin)
|
||||||
)
|
|
||||||
|
|
||||||
transport = ASGITransport(app=app_with_user)
|
transport = ASGITransport(app=app_with_user)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
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``
|
验证 GlobalToolManager 从 manifest.json 正确读取工具元数据。
|
||||||
扫描时注入到 ``tool_metadata`` 中)。这里只验证子类对 BaseToolData 字段的覆写。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from kilostar.plugin.tool_plugin.approval.approval import ApprovalToolData
|
import json
|
||||||
from kilostar.plugin.tool_plugin.file_reader import FileReaderToolData
|
from pathlib import Path
|
||||||
from kilostar.plugin.tool_plugin.tavily_search import TavilySearchToolData
|
|
||||||
|
_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():
|
def test_approval_metadata():
|
||||||
data = ApprovalToolData()
|
manifest = _read_manifest(_interactive_toolset_dir)
|
||||||
assert data.is_system is True
|
tool = _get_tool_def(manifest, "approval")
|
||||||
assert data.category == "system"
|
assert tool["is_system"] is True
|
||||||
# action_scope 为空表示分配给 default 组(所有节点可用)
|
assert tool["category"] == "system"
|
||||||
assert data.action_scope == []
|
assert tool["action_scope"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_file_reader_metadata():
|
|
||||||
data = FileReaderToolData()
|
|
||||||
assert data.is_system is True
|
|
||||||
assert data.category == "system"
|
|
||||||
assert data.action_scope == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_tavily_search_metadata():
|
def test_tavily_search_metadata():
|
||||||
data = TavilySearchToolData()
|
manifest = _read_manifest()
|
||||||
assert data.is_system is False
|
tool = _get_tool_def(manifest, "tavily_search")
|
||||||
assert data.category == "search"
|
assert tool["is_system"] is False
|
||||||
assert "control_node" in data.action_scope
|
assert tool["category"] == "search"
|
||||||
assert "consciousness_node" in data.action_scope
|
assert "control_node" in tool["action_scope"]
|
||||||
# 默认配置 schema 含 api_key 字段(用于 GSM 配置面板)
|
assert "consciousness_node" in tool["action_scope"]
|
||||||
assert "api_key" in data.config_args
|
assert "api_key" in tool["config_args"]
|
||||||
|
|
||||||
|
|
||||||
def test_base_tool_extra_allowed():
|
def test_all_tool_files_exist():
|
||||||
"""``ConfigDict(extra="allow")`` 允许子类外的 KV 也能装进来。"""
|
for toolset_dir in (_base_toolset_dir, _interactive_toolset_dir):
|
||||||
data = ApprovalToolData(some_extension="ok") # type: ignore[call-arg]
|
manifest = _read_manifest(toolset_dir)
|
||||||
assert data.model_extra is not None
|
for tool in manifest["tools"]:
|
||||||
assert data.model_extra.get("some_extension") == "ok"
|
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
|
验证 StandaloneProxy / _MethodProxy / actor_class / remote_task
|
||||||
在单机模式下的行为是否正确模拟了 Ray Actor Handle 的 .remote() 接口。
|
在单机模式下的行为是否正确模拟了 Ray Actor Handle 的 .remote() 接口。
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from kilostar.utils import standalone_proxy
|
from kilostar.utils import ray_compat
|
||||||
from kilostar.utils.standalone_proxy import StandaloneProxy, _MethodProxy
|
from kilostar.utils.ray_compat import StandaloneProxy, _MethodProxy
|
||||||
|
|
||||||
|
|
||||||
class TestMethodProxy:
|
class TestMethodProxy:
|
||||||
@@ -61,9 +61,9 @@ class TestStandaloneProxy:
|
|||||||
|
|
||||||
class TestActorClass:
|
class TestActorClass:
|
||||||
def test_standalone_returns_class_unchanged(self, monkeypatch):
|
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:
|
class MyActor:
|
||||||
def do_work(self):
|
def do_work(self):
|
||||||
return 42
|
return 42
|
||||||
@@ -72,9 +72,9 @@ class TestActorClass:
|
|||||||
assert instance.do_work() == 42
|
assert instance.do_work() == 42
|
||||||
|
|
||||||
def test_standalone_class_is_plain_python(self, monkeypatch):
|
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:
|
class MyActor:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -84,9 +84,9 @@ class TestActorClass:
|
|||||||
|
|
||||||
class TestRemoteTask:
|
class TestRemoteTask:
|
||||||
def test_sync_task(self, monkeypatch):
|
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):
|
def multiply(a, b):
|
||||||
return a * b
|
return a * b
|
||||||
|
|
||||||
@@ -95,9 +95,9 @@ class TestRemoteTask:
|
|||||||
assert result == 12
|
assert result == 12
|
||||||
|
|
||||||
def test_async_task(self, monkeypatch):
|
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):
|
async def async_multiply(a, b):
|
||||||
return a * b
|
return a * b
|
||||||
|
|
||||||
Reference in New Issue
Block a user