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:
2026-06-17 05:20:00 +00:00
parent 9b73ae4db4
commit 6d658b4f4d
74 changed files with 2591 additions and 1308 deletions
+3
View File
@@ -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
+3 -54
View File
@@ -9,7 +9,7 @@
[![Pydantic-AI](https://img.shields.io/badge/Framework-Pydantic--AI-ff69b4.svg)](https://ai.pydantic.dev/) [![Pydantic-AI](https://img.shields.io/badge/Framework-Pydantic--AI-ff69b4.svg)](https://ai.pydantic.dev/)
[![License](https://img.shields.io/badge/license-Apache--2.0-green.svg)](LICENSE) [![License](https://img.shields.io/badge/license-Apache--2.0-green.svg)](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,
),
)
+205
View File
@@ -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 # 统一门面:组合所有子 DAOready_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 Actorbootstrap/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 # AppSettingspydantic-settings+ 路径工具
│ │ │ # get_settings / get_toolset_dir / get_plugin_dir / get_artifact_dir
│ │ ├── config_loader.py # 多 YAML 统一加载 → AppConfigworkflow/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 双层 IDrequest_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 # 特殊 Workerembedding/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(普通对象)和 distributedRay Actor)两种运行形态 |
| `ray_actor_hook(name)` | 按名字获取 actor 句柄,两种模式下接口一致 |
| `StandaloneProxy` | 将普通方法调用包装为 `.remote()` 语法兼容 |
| GSM Snapshot | 不可变快照放入 Object Store,各节点快速读取无需 RPC |
| DAO 门面 | `PostgresDatabase` 组合所有子 DAO`ready_event` 确保初始化后才放行 |
| manifest.json 声明式 | 工具集/插件元数据与代码分离,支持热发现和前端展示 |
| dispatch / submit 双通道 | 重型插件对内阁阻塞(dispatch),对用户射后不管(submit) |
+46 -12
View File
@@ -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} />;
</div> }
)) return (
<div key={index} className={`p-2.5 rounded-lg text-xs ${log.content.startsWith('[You]') ? 'bg-accent-light/50 text-accent-text ml-8' : 'bg-bg-secondary text-text-secondary mr-8'}`}>
{log.content}
</div>
);
})
)} )}
<div ref={logsEndRef} /> <div 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>
);
}
+70 -13
View File
@@ -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,19 +197,51 @@ 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">
<p className="text-xs font-medium text-text-secondary mb-2"> {toolset.is_system ? (
{t('plugin.toolsetTools')} readmeState === 'loading' ? (
</p> <div className="flex items-center justify-center py-8 text-text-muted">
{toolset.tools.map((tool) => ( <Loader2 size={18} className="animate-spin mr-2" />
<div <span className="text-sm">{t('plugin.toolsetReadmeLoading')}</span>
key={tool} </div>
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg" ) : readmeState === 'loaded' && readme ? (
> <div className="prose-chat text-[13.5px] text-text-primary leading-[1.75]">
<Package size={14} className="text-accent shrink-0" /> <ReactMarkdown remarkPlugins={[remarkGfm]}>
<span className="text-sm text-text-primary">{tool}</span> {readme}
</ReactMarkdown>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-text-muted mb-3">
{t('plugin.toolsetReadmeMissing')}
</p>
{toolset.tools.map((tool) => (
<div
key={tool}
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
>
<Package size={14} className="text-accent shrink-0" />
<span className="text-sm text-text-primary">{tool}</span>
</div>
))}
</div>
)
) : (
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary mb-2">
{t('plugin.toolsetTools')}
</p>
{toolset.tools.map((tool) => (
<div
key={tool}
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
>
<Package size={14} className="text-accent shrink-0" />
<span className="text-sm text-text-primary">{tool}</span>
</div>
))}
</div> </div>
))} )}
</div> </div>
</div> </div>
</div> </div>
+5
View File
@@ -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",
+5
View File
@@ -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": "安装技能",
+3 -1
View File
@@ -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)
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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 = ""
+108
View File
@@ -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}
+1 -2
View File
@@ -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
+80 -8
View File
@@ -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 位 hexuuid4 前缀),防止穿越
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()
+1 -2
View File
@@ -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,
+1 -2
View File
@@ -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"], 逻辑分组,这里会一并清理掉,避免遗留脏数据。
owner_id=None, """
is_system=True, packages = self._global_tool_manager.toolset_packages
category=seed["category"], wanted_ids = {f"system::{name}" for name in packages.keys()}
)
self._custom_toolsets[seed["toolset_id"]] = seed # 清理 stale 系统 toolset(包括旧版硬编码的 system_basic/system_chat/...
for tid, ts in list(self._custom_toolsets.items()):
if not ts.get("is_system"):
continue
if tid in wanted_ids:
continue
await self.postgres_database.delete_custom_toolset.remote(tid)
self._custom_toolsets.pop(tid, None)
for name, pkg in packages.items():
tid = f"system::{name}"
saved = await self.postgres_database.upsert_custom_toolset.remote(
toolset_id=tid,
name=pkg.get("display_name") or name,
tools=list(pkg.get("tools", [])),
description=pkg.get("description") or None,
owner_id=None,
is_system=True,
category=name,
)
self._custom_toolsets[tid] = saved
# ─── Snapshot 发布(Object Store 读路径) ──────────────────── # ─── 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)
try: self._build_system_toolsets()
module = importlib.import_module(module_name) self._build_retrieval_toolsets()
except Exception as e:
logger.warning(f"Failed to import tool plugin {plugin_name}: {e}") def _load_toolset(self, toolset_dir, manifest_path) -> None:
"""从一个 toolset 目录加载 manifest 并注册所有工具。"""
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
except Exception as e:
logger.warning(f"Failed to read manifest {manifest_path}: {e}")
return
toolset_name = toolset_dir.name
# 注册 toolset 的虚拟包
pkg_name = f"data.toolset.{toolset_name}"
if pkg_name not in sys.modules:
mod = types.ModuleType(pkg_name)
mod.__path__ = [str(toolset_dir)]
mod.__package__ = pkg_name
sys.modules[pkg_name] = mod
registered_tools: List[str] = []
for tool_def in manifest.get("tools", []):
tool_name = tool_def.get("name")
tool_file = tool_def.get("file", f"{tool_name}.py")
if not tool_name:
continue 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。"""
系统 toolsetis_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="外部任务 IDUUID"
)
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
]
+30 -1
View File
@@ -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
-13
View File
@@ -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.
-13
View File
@@ -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 @@
{
}
-38
View File
@@ -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}"
+29
View File
@@ -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",
]
+50
View File
@@ -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-effortPG 不可用时静默)
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
+63
View File
@@ -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
+128
View File
@@ -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}")
+57
View File
@@ -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}"
+137
View File
@@ -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 handlestandalone=proxydist=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})")
+52
View File
@@ -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()
}
+61 -2
View File
@@ -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
+3 -4
View File
@@ -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"""
██╗ ██╗██╗██╗ ██████╗ ███████╗████████╗ █████╗ ██████╗ ██╗ ██╗██╗██╗ ██████╗ ███████╗████████╗ █████╗ ██████╗
██║ ██╔╝██║██║ ██╔═══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗ ██║ ██╔╝██║██║ ██╔═══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗
-71
View File
@@ -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
View File
@@ -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:
-112
View File
@@ -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
-39
View File
@@ -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
+121
View File
@@ -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
+2 -2
View File
@@ -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)
+46
View File
@@ -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"
+1 -1
View File
@@ -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)
+15 -1
View File
@@ -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())
+2 -2
View File
@@ -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()
+5 -7
View File
@@ -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:
+78 -30
View File
@@ -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
+63
View File
@@ -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