diff --git a/.gitignore b/.gitignore index 3237735..0e7ac78 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,8 @@ wheels/ .idea # Local runtime data (MCP registry, etc.) data/ +!data/plugin/ +data/plugin/skill/ +!data/toolset/ tmp/ .env diff --git a/README.md b/README.md index 285eba1..9da4c7a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![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) -[English](./README-EN.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**未来展望**](./changelogs/ROADMAP.md) +[English](./README-EN.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**项目结构**](docs/STRUCTURE.md) | [**未来展望**](./changelogs/ROADMAP.md) @@ -53,7 +53,7 @@ ### 🧩 插件体系 - **工具插件**:标准 Tool 调用,支持 MCP 协议接入第三方服务 - **Skill(兼容 Anthropic Agent Skills 标准)**:通过 [viceroy](https://github.com/zhaoxi826/viceroy) 安装解析,运行时按需加载 -- **重型插件(规划中)**:带独立 UI 的垂直应用包,把 KiloStar 改造成专用 Agent 平台 +- **重型插件(Organization)**:带独立工具集、多 Agent 团队与前端面板的垂直应用包,以"部门"身份接入系统内阁 ### 🛡️ 安全设计 - **JWT 鉴权**:所有 API 端点(含 SSE 事件流)均走 Bearer Token 认证 @@ -129,58 +129,7 @@ cd frontend && npm install && npm run dev ## 📁 项目结构 -``` -KiloStar/ -├── main.py # 应用入口(FastAPI + Ray 初始化) -├── pyproject.toml # Python 依赖与项目元数据 -├── Dockerfile / docker-compose.yml # 容器化部署 -├── alembic/ # 数据库迁移脚本 -├── config/ # 环境配置模板 -├── kilostar/ # 后端核心包 -│ ├── api/ # FastAPI 路由层 -│ │ ├── system.py # /health 系统健康检查 -│ │ ├── workflow.py # /workflow CRUD + SSE + resume -│ │ ├── chat.py # /chat 会话管理 -│ │ ├── agent.py # /agent Worker 管理 -│ │ └── resource.py # /resource Skill/Toolset 管理 -│ ├── core/ # 核心业务逻辑 -│ │ ├── individual/ # 各类 Agent 节点实现 -│ │ │ ├── consciousness_node/ # 意识节点(任务规划) -│ │ │ ├── regulatory_node/ # 监管节点(质量把关) -│ │ │ ├── control_node/ # 控制节点(路由调度) -│ │ │ └── growth_node/ # 生长节点(能力扩展) -│ │ ├── work/ # 工作执行层 -│ │ │ ├── workflow/ # 工作流引擎(pydantic-graph) -│ │ │ ├── chat/ # 对话处理 -│ │ │ └── task/ # 单任务执行 -│ │ ├── global_state_machine/ # 全局状态机(Provider/Config) -│ │ ├── global_workflow_manager/ # 工作流消息队列 Actor -│ │ └── postgres_database/ # PostgreSQL DAO 层 -│ ├── adapter/ # 模型适配器(OpenAI/vLLM/...) -│ ├── plugin/ # 工具插件 -│ │ └── tool_plugin/ # Tavily / FileReader / Approval -│ ├── utils/ # 工具函数 -│ │ ├── access.py # JWT 认证 -│ │ ├── ray_hook.py # Ray Actor 句柄获取 -│ │ └── check_user/ # 角色鉴权 -│ ├── worker_cluster/ # Worker 集群管理 -│ └── worker_individual/ # Worker 个体生命周期 -├── frontend/ # React 前端(Vite + Tailwind) -│ └── src/ -│ ├── api/ # Axios client + SSE 封装 -│ ├── components/ # UI 组件 -│ │ ├── Chat/ # 工作流面板 + 实时图 -│ │ ├── Agent/ # Worker/Provider 管理 -│ │ ├── Plugin/ # Skill/Tool 配置 -│ │ └── Settings/ # 系统设置 -│ ├── i18n/ # 国际化(中/英) -│ ├── store/ # Zustand 状态管理 -│ └── types/ # TypeScript 类型定义 -├── tests/ # 测试套件(249+ 用例) -│ ├── unit/ # 单元测试 -│ └── integration/ # 集成 smoke 测试 -└── docs/ # 设计文档 -``` +详见 [docs/STRUCTURE.md](docs/STRUCTURE.md)。 --- diff --git a/alembic/versions/2026_06_16_0000-0009_org_task_tables.py b/alembic/versions/2026_06_16_0000-0009_org_task_tables.py new file mode 100644 index 0000000..7418a3f --- /dev/null +++ b/alembic/versions/2026_06_16_0000-0009_org_task_tables.py @@ -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, + ), + ) diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md new file mode 100644 index 0000000..791ba7a --- /dev/null +++ b/docs/STRUCTURE.md @@ -0,0 +1,205 @@ +# 项目结构 + +> 最后更新:2026-06-17 + +``` +KiloStar/ +├── main.py # 应用入口(standalone / distributed 双模式) +├── pyproject.toml # Python 依赖与项目元数据(uv 管理) +├── Dockerfile / docker-compose.yml # 容器化部署 +├── alembic/ # 数据库迁移脚本(顺序编号 0001~0009) +├── config/ # 环境配置模板 +│ ├── .env.example # 环境变量模板 +│ ├── config.yml # 应用配置(provider/node 默认值) +│ ├── workflow.yaml # 工作流重试策略 +│ └── sandbox.yaml # 沙箱策略(路径白名单/命令过滤) +│ +├── kilostar/ # ===== 后端核心包 ===== +│ │ +│ ├── api/ # FastAPI 路由层(每个文件一个 APIRouter) +│ │ ├── __init__.py # app 实例、中间件、异常处理、路由挂载 +│ │ ├── system.py # GET /health + /api/v1/system 系统信息 +│ │ ├── workflow.py # /api/v1/workflow CRUD / SSE / resume +│ │ ├── chat.py # /api/v1/chat 对话(含历史上下文注入) +│ │ ├── agent.py # /api/v1/agent Worker CRUD / 模板 +│ │ ├── resource.py # /api/v1/resource Toolset / Skill / Artifact +│ │ ├── plugin.py # /api/v1/plugin 重型插件 submit/status/stream +│ │ ├── provider.py # /api/v1/provider 模型供应商 CRUD +│ │ ├── auth.py # /api/v1/auth 登录/注册/改密 +│ │ └── platform/ # 平台接入 +│ │ ├── frontend.py # SPA 静态资源 fallback +│ │ └── onebot.py # OneBot v11 协议适配 +│ │ +│ ├── core/ # 核心业务逻辑 +│ │ ├── individual/ # 系统 Agent 节点 +│ │ │ ├── consciousness_node/ # 意识节点:接收用户命令 → 设计工作流 DAG +│ │ │ │ ├── consciousness_node.py +│ │ │ │ └── template.py # structured output 模板 +│ │ │ ├── regulatory_node/ # 监管节点:直面用户对话、质量把关 +│ │ │ │ ├── regulatory_node.py +│ │ │ │ └── template.py +│ │ │ ├── control_node/ # 控制节点:工作流节点内路由调度 +│ │ │ │ ├── control_node.py +│ │ │ │ └── template.py +│ │ │ └── growth_node/ # 生长节点:能力自扩展(占位) +│ │ │ +│ │ ├── work/ # 工作执行层 +│ │ │ ├── workflow/ # 工作流引擎 +│ │ │ │ ├── workflow_engine.py # 轮询 + 调度主循环 +│ │ │ │ ├── workflow.py # pydantic-graph 节点定义 +│ │ │ │ ├── model.py # WorkflowState / StepResult 等 +│ │ │ │ └── graph_persistence.py # 执行状态持久化到 PG +│ │ │ ├── chat/ # 对话处理(占位) +│ │ │ └── task/ # 短任务执行(占位) +│ │ │ +│ │ ├── global_state_machine/ # 全局状态机 Actor(系统唯一真相源) +│ │ │ ├── global_state_machine.py # GSM 主体:初始化、注册表读写、toolset 补种 +│ │ │ ├── gsm_snapshot.py # 不可变快照(放入 Ray Object Store 供快读) +│ │ │ ├── individual_manager.py # Individual 注册/查询/删除 +│ │ │ ├── provider_manager.py # Provider 注册/CRUD/test_connection +│ │ │ ├── tool_manager.py # Toolset 加载(读 manifest.json)+ 工具分发 +│ │ │ ├── skill_manager.py # Skill 元数据注册/查询 +│ │ │ └── model_provider/ # Provider 适配(per-vendor 子类) +│ │ │ ├── base_provider.py # 抽象基类 +│ │ │ ├── openai_provider.py # OpenAI / 兼容接口 +│ │ │ ├── claude_provider.py # Anthropic Claude +│ │ │ ├── gemini_provider.py # Google Gemini +│ │ │ └── deepseek_provider.py # DeepSeek +│ │ │ +│ │ ├── global_workflow_manager/ # 工作流调度 Actor +│ │ │ └── global_workflow_manager.py # 消息队列、pending workflow 轮询 +│ │ │ +│ │ └── postgres_database/ # PostgreSQL DAO 层(Actor 门面模式) +│ │ ├── postgres.py # 统一门面:组合所有子 DAO,ready_event 守卫 +│ │ ├── database_exception.py# @database_exception 装饰器(统一异常包装) +│ │ ├── model/ # SQLAlchemy ORM 模型 +│ │ │ ├── base.py # DeclarativeBase +│ │ │ ├── user.py # User + UserAuthority +│ │ │ ├── provider.py # ProviderModel +│ │ │ ├── individual.py # Base/Specialist/Ordinary/Special Individual +│ │ │ ├── workflow.py # Workflow + Context + GraphState +│ │ │ ├── chat_history.py # ChatHistoryRegister + Message +│ │ │ ├── system_node.py # SystemNodeConfigModel +│ │ │ ├── mcp_server.py # MCPServerModel +│ │ │ ├── tool_config.py # ToolConfigModel +│ │ │ ├── custom_toolset.py# CustomToolsetModel +│ │ │ ├── persona_template.py# PersonaTemplate +│ │ │ ├── system_event_log.py# SystemEventLog +│ │ │ ├── org_task.py # OrgTask(重型插件任务) +│ │ │ └── org_task_event.py# OrgTaskEvent(任务事件流) +│ │ └── module/ # 各表 DAO 实现(async session + CRUD) +│ │ ├── user.py / provider.py / individual.py / ... +│ │ └── org_task.py # 重型插件任务 + 事件 DAO +│ │ +│ ├── plugin_runtime/ # 重型插件(Organization)运行时 +│ │ ├── base_organization.py # 基类:asyncio.Queue 消费、dispatch/submit 双通道 +│ │ │ # react 循环、consult 工具、PG 持久化 +│ │ ├── plugin_manager.py # GlobalPluginManager Actor:bootstrap/install/reload +│ │ ├── loader.py # discover_plugins + load_plugin + uv 依赖安装 +│ │ ├── tool_bridge.py # make_dispatch_tool() → 生成 dispatch_to_ 函数 +│ │ ├── manifest.py # OrgManifest pydantic 模型 +│ │ ├── agents_config.py # AgentsConfig / AgentDef / orchestration +│ │ └── event.py # OrgEvent / OrgEventType / TaskState +│ │ +│ ├── adapter/ # 模型适配器 +│ │ └── model_adapter/ +│ │ ├── agent_factory.py # AgentFactory:根据 provider+model 构建 pydantic-ai Agent +│ │ └── deepseek_reasoner.py # DeepSeek R1 reasoning 特殊适配 +│ │ +│ ├── utils/ # ===== 工具函数层 ===== +│ │ ├── settings.py # AppSettings(pydantic-settings)+ 路径工具 +│ │ │ # get_settings / get_toolset_dir / get_plugin_dir / get_artifact_dir +│ │ ├── config_loader.py # 多 YAML 统一加载 → AppConfig(workflow/sandbox/应用配置) +│ │ ├── ray_compat.py # standalone/distributed 兼容层 +│ │ │ # @actor_class 装饰器、StandaloneProxy、_STANDALONE 标志 +│ │ ├── ray_hook.py # ray_actor_hook():按名字获取 Actor 句柄(两种模式统一) +│ │ ├── access.py # JWT 认证 + RBAC 鉴权(Accessor / TokenData / RoleChecker) +│ │ ├── crypto.py # Fernet 对称加密(API key 等敏感字段落盘加密) +│ │ ├── error.py # 统一异常体系:KiloStarError / BusinessError / InfraError +│ │ ├── logger.py # loguru + rich 日志(get_logger 按模块名取 logger) +│ │ ├── request_context.py # contextvars 双层 ID:request_id + trace_id 传播 +│ │ ├── get_tool.py # 按工具名动态加载函数(扫描 manifest → importlib) +│ │ ├── mcp_helper.py # MCP Server 实例创建(stdio/sse/http 三种传输) +│ │ ├── sandbox.py # 工具沙箱:路径校验、命令黑名单、Python AST 检查 +│ │ ├── agent_model.py # Agent 通用 pydantic 响应模型(ResponseModel 等) +│ │ ├── prompts.py # 系统节点 system prompt 模板(按角色×locale) +│ │ ├── rate_limit.py # 滑动窗口内存限流器(按 IP,单实例用) +│ │ ├── retry.py # @retry_on_retryable_error 装饰器(指数退避) +│ │ ├── banner.py # 启动 banner ASCII art +│ │ └── i18n.py # 国际化翻译(t() 函数,accept-language 解析) +│ │ +│ ├── worker_cluster/ # Worker 集群管理 +│ │ └── worker_cluster.py # WorkerCluster Actor:按资源标签管理 worker 池 +│ │ # CPU / Core / GPU 三类,分布式下各一个 Actor +│ │ +│ └── worker_individual/ # Worker 个体 +│ ├── base_individual.py # 抽象基类(生命周期 + 工具绑定) +│ ├── ordinary_individual.py # 通用 Worker(接受任意 system prompt) +│ ├── skill_individual.py # Skill Worker(加载指定 skill 执行) +│ └── special_individual.py # 特殊 Worker(embedding/TTS/图像,占位) +│ +├── data/ # ===== 数据/插件目录(运行时读取)===== +│ ├── toolset/ # 工具集(每个子目录 = 一个 toolset) +│ │ ├── base_toolset/ # 系统基础工具 +│ │ │ ├── manifest.json # 声明 7 个工具的元数据 +│ │ │ ├── shell_executor.py # Shell 命令执行 +│ │ │ ├── file_reader.py # 文件读取 +│ │ │ ├── edit_file.py # 文件编辑(diff patch) +│ │ │ ├── write_file.py # 文件写入 +│ │ │ ├── search_file.py # 文件搜索(glob + grep) +│ │ │ ├── python_executor.py # Python 代码执行(沙箱内) +│ │ │ └── tavily_search.py # Tavily 网络搜索 +│ │ └── interactive_toolset/ # 交互工具(需要人/系统介入) +│ │ ├── manifest.json +│ │ ├── approval.py # 人工审批节点 +│ │ └── send_file.py # 文件下发(存 artifact + 推 SSE) +│ │ +│ └── plugin/ # 重型插件(每个子目录 = 一个 Organization) +│ └── example_dept/ # 示例插件(开发模板) +│ ├── manifest.json # 插件元数据(name/entry/concurrency/...) +│ ├── agents.json # 内部 agent 定义(analyst + executor) +│ ├── README.md +│ ├── core/ # 业务逻辑入口 +│ │ └── organization.py # ExampleOrganization(BaseOrganization) +│ ├── toolset/ # 插件本地工具(可选) +│ ├── skills/ # 插件本地技能(可选) +│ └── dashboard/ # 前端面板(占位,Tauri 化后接通) +│ +├── frontend/ # ===== React 前端(Vite + TypeScript + Tailwind)===== +│ └── src/ +│ ├── api/ # Axios client + SSE 封装 + 类型化请求 +│ ├── assets/ # 静态资源(图标/字体) +│ ├── hooks/ # 自定义 React hooks +│ ├── components/ # UI 组件 +│ │ ├── Chat/ # 工作流面板 + 实时日志 + 文件卡片 +│ │ ├── Agent/ # Worker / Provider 管理 +│ │ ├── Plugin/ # Skill / Toolset / MCP 配置 +│ │ ├── Auth/ # 登录 / 注册 +│ │ ├── Layout/ # 布局骨架 + 导航 +│ │ └── Settings/ # 系统设置 +│ ├── i18n/ # 国际化 +│ │ └── locales/ # zh.json / en.json +│ ├── store/ # Zustand 状态管理 +│ └── types/ # TypeScript 类型定义 +│ +├── tests/ # ===== 测试套件(331 用例)===== +│ ├── unit/ # 单元测试(纯逻辑,mock 外部依赖) +│ └── integration/ # 集成测试(启动真实服务) +│ +└── subprojects/ # ===== 子项目 ===== + └── stardomain/ # Skill 脚本沙箱执行(local + Docker 双模式) +``` + +--- + +## 关键设计模式 + +| 模式 | 说明 | +|:--|:--| +| `@actor_class` | 装饰器统一 standalone(普通对象)和 distributed(Ray Actor)两种运行形态 | +| `ray_actor_hook(name)` | 按名字获取 actor 句柄,两种模式下接口一致 | +| `StandaloneProxy` | 将普通方法调用包装为 `.remote()` 语法兼容 | +| GSM Snapshot | 不可变快照放入 Object Store,各节点快速读取无需 RPC | +| DAO 门面 | `PostgresDatabase` 组合所有子 DAO,`ready_event` 确保初始化后才放行 | +| manifest.json 声明式 | 工具集/插件元数据与代码分离,支持热发现和前端展示 | +| dispatch / submit 双通道 | 重型插件对内阁阻塞(dispatch),对用户射后不管(submit) | \ No newline at end of file diff --git a/frontend/src/components/Chat/RightPanel.tsx b/frontend/src/components/Chat/RightPanel.tsx index fe805b3..b758869 100644 --- a/frontend/src/components/Chat/RightPanel.tsx +++ b/frontend/src/components/Chat/RightPanel.tsx @@ -7,6 +7,35 @@ import type { SSEConnection } from '../../api/sse'; import type { WorkflowDetail } from '../../types'; import { ErrorBoundary } from '../ErrorBoundary'; import { WorkflowDiagram } from './WorkflowDiagram'; +import { WorkflowFileCard, type WorkflowFilePayload } from './WorkflowFileCard'; + +type LogEntry = + | { kind: 'text'; content: string } + | { kind: 'file'; payload: WorkflowFilePayload }; + +const FILE_PREFIX = '__FILE__'; + +function parseLogEvent(data: string): LogEntry { + if (data.startsWith(FILE_PREFIX)) { + try { + const parsed = JSON.parse(data.slice(FILE_PREFIX.length)); + if (parsed && typeof parsed.filename === 'string' && typeof parsed.url === 'string') { + return { + kind: 'file', + payload: { + filename: parsed.filename, + url: parsed.url, + artifact_id: parsed.artifact_id, + size: typeof parsed.size === 'number' ? parsed.size : undefined, + }, + }; + } + } catch { + /* fall through to text */ + } + } + return { kind: 'text', content: data }; +} interface RightPanelProps { selectedWorkflow: string | null; @@ -16,7 +45,7 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) { const { t } = useTranslation(); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); - const [logs, setLogs] = useState([]); + const [logs, setLogs] = useState([]); const [sseConnected, setSseConnected] = useState(false); const [replyText, setReplyText] = useState(''); const [resuming, setResuming] = useState(false); @@ -54,13 +83,13 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) { token, { onOpen: () => setSseConnected(true), - onMessage: (data) => setLogs((prev) => [...prev, data]), + onMessage: (data) => setLogs((prev) => [...prev, parseLogEvent(data)]), onError: () => setSseConnected(false), onReconnect: (delayMs) => { setSseConnected(false); setLogs((prev) => [ ...prev, - `[System]: ${t('workflow.sseReconnecting', { seconds: Math.round(delayMs / 1000) })}`, + { kind: 'text', content: `[System]: ${t('workflow.sseReconnecting', { seconds: Math.round(delayMs / 1000) })}` }, ]); }, }, @@ -82,11 +111,11 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) { if (!replyText.trim() || !selectedWorkflow) return; const message = replyText.trim(); setReplyText(''); - setLogs((prev) => [...prev, `[You]: ${message}`]); + setLogs((prev) => [...prev, { kind: 'text', content: `[You]: ${message}` }]); try { await apiClient.post(`/api/v1/workflow/reply/${selectedWorkflow}`, { message }); } catch { - setLogs((prev) => [...prev, `[System Error]: Failed to send reply.`]); + setLogs((prev) => [...prev, { kind: 'text', content: `[System Error]: Failed to send reply.` }]); } }; @@ -95,11 +124,11 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) { setResuming(true); try { await apiClient.post(`/api/v1/workflow/${selectedWorkflow}/resume`); - setLogs((prev) => [...prev, `[System]: ${t('workflow.resumeTriggered')}`]); + setLogs((prev) => [...prev, { kind: 'text', content: `[System]: ${t('workflow.resumeTriggered')}` }]); fetchDetail(selectedWorkflow); } catch (err: any) { const detailMsg = err?.response?.data?.detail || t('workflow.resumeFailed'); - setLogs((prev) => [...prev, `[System Error]: ${detailMsg}`]); + setLogs((prev) => [...prev, { kind: 'text', content: `[System Error]: ${detailMsg}` }]); } finally { setResuming(false); } @@ -220,11 +249,16 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) { {t('workflow.waitingEvents')} ) : ( - logs.map((log, index) => ( -
- {log} -
- )) + logs.map((log, index) => { + if (log.kind === 'file') { + return ; + } + return ( +
+ {log.content} +
+ ); + }) )}
diff --git a/frontend/src/components/Chat/WorkflowFileCard.tsx b/frontend/src/components/Chat/WorkflowFileCard.tsx new file mode 100644 index 0000000..01ac101 --- /dev/null +++ b/frontend/src/components/Chat/WorkflowFileCard.tsx @@ -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(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 ( +
+
+
+ +
+
+
+ {payload.filename} +
+ {payload.size !== undefined && ( +
+ {formatSize(payload.size)} +
+ )} +
+ +
+ {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Plugin/ToolSettings.tsx b/frontend/src/components/Plugin/ToolSettings.tsx index fdfe036..2483df2 100644 --- a/frontend/src/components/Plugin/ToolSettings.tsx +++ b/frontend/src/components/Plugin/ToolSettings.tsx @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Package, Wrench, Loader2, Box, Shield, X } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import apiClient from '../../api/client'; interface Toolset { @@ -145,13 +147,36 @@ function ToolsetModal({ onClose: () => void; }) { const { t } = useTranslation(); + const [readme, setReadme] = useState(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 (
e.stopPropagation()} >
@@ -172,19 +197,51 @@ function ToolsetModal({
-
-

- {t('plugin.toolsetTools')} -

- {toolset.tools.map((tool) => ( -
- - {tool} +
+ {toolset.is_system ? ( + readmeState === 'loading' ? ( +
+ + {t('plugin.toolsetReadmeLoading')} +
+ ) : readmeState === 'loaded' && readme ? ( +
+ + {readme} + +
+ ) : ( +
+

+ {t('plugin.toolsetReadmeMissing')} +

+ {toolset.tools.map((tool) => ( +
+ + {tool} +
+ ))} +
+ ) + ) : ( +
+

+ {t('plugin.toolsetTools')} +

+ {toolset.tools.map((tool) => ( +
+ + {tool} +
+ ))}
- ))} + )}
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fbe47f4..1a0de00 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -83,6 +83,9 @@ "resumeTriggered": "Resume request sent, the workflow is recovering...", "resumeFailed": "Failed to resume workflow", "sseReconnecting": "Connection lost, retrying in {{seconds}}s...", + "fileDownload": "Download", + "fileDownloadFailed": "Download failed", + "fileForbidden": "Not allowed to download this file", "workflowDetails": "Workflow Details", "loading": "Loading Workflows...", "titleRequired": "Please enter a workflow title", @@ -262,6 +265,8 @@ "toolsetEmpty": "No toolsets available", "toolsetSystem": "System", "toolsetCount": "{{count}} tools", + "toolsetReadmeMissing": "No README provided for this package", + "toolsetReadmeLoading": "Loading description…", "skillManagement": "Skill Management", "skillDesc": "Manage agent skills and functions", "installSkill": "Install Skill", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index d931408..e2b9af8 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -83,6 +83,9 @@ "resumeTriggered": "恢复请求已发送,工作流正在恢复中...", "resumeFailed": "恢复工作流失败", "sseReconnecting": "连接断开,{{seconds}}秒后重试...", + "fileDownload": "下载附件", + "fileDownloadFailed": "下载失败", + "fileForbidden": "无权下载该文件", "workflowDetails": "工作流详情", "loading": "正在加载工作流...", "titleRequired": "请输入工作流标题", @@ -262,6 +265,8 @@ "toolsetEmpty": "暂无工具集", "toolsetSystem": "系统", "toolsetCount": "{{count}} 个工具", + "toolsetReadmeMissing": "该工具包没有提供 README", + "toolsetReadmeLoading": "正在加载说明…", "skillManagement": "技能管理", "skillDesc": "管理代理技能和函数", "installSkill": "安装技能", diff --git a/kilostar/api/__init__.py b/kilostar/api/__init__.py index 8211133..9b01c39 100644 --- a/kilostar/api/__init__.py +++ b/kilostar/api/__init__.py @@ -20,7 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from kilostar.utils.standalone_proxy import _STANDALONE +from kilostar.utils.ray_compat import _STANDALONE from kilostar.utils.settings import get_settings if not _STANDALONE: @@ -35,6 +35,7 @@ from .provider import provider_router from .resource import resource_router from .workflow import workflow_router from .chat import chat_router +from .plugin import plugin_router from kilostar.utils.error import ( KiloStarError, BusinessError, @@ -103,6 +104,7 @@ app.include_router(resource_router) # 资源路径 app.include_router(agent_router) # agent路径 app.include_router(workflow_router) # workflow路径 app.include_router(chat_router) # chat路径 +app.include_router(plugin_router) # plugin路径 @app.exception_handler(BusinessError) diff --git a/kilostar/api/agent.py b/kilostar/api/agent.py index b8f0e10..167e43f 100644 --- a/kilostar/api/agent.py +++ b/kilostar/api/agent.py @@ -17,11 +17,10 @@ from typing import Union from kilostar.utils.ray_hook import ray_actor_hook from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, field_validator -from kilostar.utils.access import Accessor, TokenData +from kilostar.utils.access import Accessor, TokenData, RoleChecker from kilostar.core.postgres_database.model import AgentType from fastapi import HTTPException from typing import Optional, List, Dict -from kilostar.utils.check_user.role_check import RoleChecker from kilostar.core.postgres_database.model import UserAuthority from kilostar.utils.mcp_helper import get_all_tools_and_toolsets_for_scope from kilostar.utils.i18n import t diff --git a/kilostar/api/auth.py b/kilostar/api/auth.py index fa9fcfc..8312cc1 100644 --- a/kilostar/api/auth.py +++ b/kilostar/api/auth.py @@ -15,10 +15,9 @@ from fastapi import APIRouter, Request from fastapi import Depends from pydantic import BaseModel -from kilostar.utils.access import Accessor, TokenData +from kilostar.utils.access import Accessor, TokenData, RoleChecker from fastapi.concurrency import run_in_threadpool from kilostar.utils.ray_hook import ray_actor_hook -from kilostar.utils.check_user.role_check import RoleChecker from kilostar.core.postgres_database.model import UserAuthority from kilostar.utils.error import UserNotExistError from kilostar.utils.rate_limit import register_limiter, login_limiter diff --git a/kilostar/api/chat.py b/kilostar/api/chat.py index 8a2fc38..1abe3e9 100644 --- a/kilostar/api/chat.py +++ b/kilostar/api/chat.py @@ -26,6 +26,40 @@ from kilostar.core.individual.regulatory_node.template import ( chat_router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) +# 单次注入历史的最大轮数(user+assistant 算一轮),防止 token 爆炸。 +_HISTORY_MAX_TURNS = 20 + + +def _build_message_history(rows) -> list: + """把 DB 中的 ChatHistoryMessage 列表转成 pydantic-ai message_history 格式。 + + 历史按时间升序,截取末尾最多 _HISTORY_MAX_TURNS*2 条;user 消息映射为 + ``ModelRequest(parts=[UserPromptPart])``,assistant(``regulatory_node``)映射为 + ``ModelResponse(parts=[TextPart])``。其它 owner 跳过。 + """ + from pydantic_ai.messages import ( + ModelRequest, ModelResponse, UserPromptPart, TextPart, + ) + + trimmed = rows[-(_HISTORY_MAX_TURNS * 2):] + history: list = [] + for row in trimmed: + owner = row.message_owner + text = row.message + if not text: + continue + if owner == "user": + history.append(ModelRequest(parts=[UserPromptPart(content=text)])) + elif owner == "regulatory_node": + history.append(ModelResponse(parts=[TextPart(content=text)])) + return history + + +async def _load_message_history(chat_id: str) -> list: + postgres_database = ray_actor_hook("postgres_database").postgres_database + rows = await postgres_database.list_chat_messages.remote(chat_id=chat_id) + return _build_message_history(rows or []) + def _extract_reply(resp: MessageResponse | None) -> str | None: """从 RegulatoryNode.working 的输出里取出对用户的回复文本。 @@ -39,7 +73,7 @@ def _extract_reply(resp: MessageResponse | None) -> str | None: async def _ask_regulatory( - *, user_id: str, chat_id: str, message: str + *, user_id: str, chat_id: str, message: str, message_history: list | None = None ) -> str | None: """统一封装 chat 入口对 RegulatoryNode 的调用。""" regulatory_node = ray_actor_hook("regulatory_node").regulatory_node @@ -49,7 +83,9 @@ async def _ask_regulatory( platform_id=chat_id, message=message, ) - resp: MessageResponse | None = await regulatory_node.working.remote(payload) + resp: MessageResponse | None = await regulatory_node.working.remote( + payload, message_history + ) return _extract_reply(resp) @@ -120,7 +156,8 @@ async def send_chat_message( token_data: TokenData = Depends(Accessor.get_current_user), ): postgres_database = ray_actor_hook("postgres_database").postgres_database - # 存用户消息 + # 先取历史(不含当前输入),再写入用户消息,避免历史里出现重复 + message_history = await _load_message_history(chat_id) await postgres_database.add_chat_message.remote( chat_id=chat_id, message=request.message, message_owner="user" ) @@ -130,6 +167,7 @@ async def send_chat_message( user_id=token_data.user_id, chat_id=chat_id, message=request.message, + message_history=message_history, ) # 存回复 @@ -164,10 +202,12 @@ async def stream_chat_message( token_data: TokenData = Depends(Accessor.get_current_user), ): """SSE 流式聊天端点:standalone 模式下逐 token 流式输出;distributed 模式 fallback 到整段回复。""" - from kilostar.utils.standalone_proxy import _STANDALONE + from kilostar.utils.ray_compat import _STANDALONE postgres_database = ray_actor_hook("postgres_database").postgres_database + message_history = await _load_message_history(chat_id) + await postgres_database.add_chat_message.remote( chat_id=chat_id, message=request_body.message, message_owner="user" ) @@ -183,7 +223,7 @@ async def stream_chat_message( if not _STANDALONE: async def fallback_generator(): - resp = await regulatory_node.working.remote(payload) + resp = await regulatory_node.working.remote(payload, message_history) full_response = resp.reply_message if resp else "" if full_response: await postgres_database.add_chat_message.remote( @@ -195,7 +235,7 @@ async def stream_chat_message( return StreamingResponse(fallback_generator(), media_type="text/event-stream") token_queue = asyncio.Queue() - stream_task = regulatory_node.stream_working.remote(payload, token_queue) + stream_task = regulatory_node.stream_working.remote(payload, token_queue, message_history) async def event_generator(): full_response = "" diff --git a/kilostar/api/plugin.py b/kilostar/api/plugin.py new file mode 100644 index 0000000..f98a6ae --- /dev/null +++ b/kilostar/api/plugin.py @@ -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} diff --git a/kilostar/api/provider.py b/kilostar/api/provider.py index 00f93d7..1c5d863 100644 --- a/kilostar/api/provider.py +++ b/kilostar/api/provider.py @@ -15,8 +15,7 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel from typing import Any, Dict, Literal -from kilostar.utils.access import TokenData, Accessor -from kilostar.utils.check_user.role_check import RoleChecker +from kilostar.utils.access import TokenData, Accessor, RoleChecker from kilostar.core.postgres_database.model import UserAuthority from kilostar.core.global_state_machine.model_provider.base_provider import Provider from kilostar.utils.ray_hook import ray_actor_hook diff --git a/kilostar/api/resource.py b/kilostar/api/resource.py index bcebeee..8ed8082 100644 --- a/kilostar/api/resource.py +++ b/kilostar/api/resource.py @@ -17,10 +17,11 @@ from pydantic import BaseModel import viceroy from kilostar.utils.ray_hook import ray_actor_hook from fastapi import APIRouter, Depends, HTTPException -from kilostar.utils.access import TokenData -from kilostar.utils.check_user.role_check import RoleChecker +from fastapi.responses import FileResponse +from kilostar.utils.access import TokenData, RoleChecker, Accessor from kilostar.core.postgres_database.model import UserAuthority from kilostar.utils.mcp_helper import list_mcp_tools_from_gsm +from kilostar.utils.settings import get_artifact_dir resource_router = APIRouter(prefix="/api/v1/resource") @@ -48,13 +49,12 @@ class MCPServerConfig(BaseModel): async def install_skill( skill: Skill, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)) ): - """通过 viceroy 把 skill 仓库克隆到 ``plugin/skill``,并在状态机中登记。""" + """通过 viceroy 把 skill 仓库克隆到 ``data/plugin/skill``,并在状态机中登记。""" global_state_machine = ray_actor_hook("global_state_machine").global_state_machine import os + from kilostar.utils.settings import get_plugin_dir - skill_output_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "plugin", "skill") - ) + skill_output_dir = str(get_plugin_dir() / "skill") os.makedirs(skill_output_dir, exist_ok=True) await viceroy.install_skill_async( url=skill.repo_url, path=skill.path, output=skill_output_dir @@ -133,6 +133,78 @@ async def delete_mcp_server( return {"message": "success"} +# ─── Workflow Artifact 下载(agent send_file 投递的文件)─── + + +@resource_router.get("/artifact/{trace_id}/{artifact_id}") +async def download_artifact( + trace_id: str, + artifact_id: str, + token_data: TokenData = Depends(Accessor.get_current_user), +): + """下载某个 trace 名下的 agent 产物文件。 + + 路径校验三件套: + 1. trace 必须存在且属于当前用户 + 2. ``artifact_id`` 限定为 12 位 hex(uuid4 前缀),防止穿越 + 3. 解析后的最终路径必须仍然落在 ``//`` 之内 + """ + 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//`` 单元)。""" + global_state_machine = ray_actor_hook("global_state_machine").global_state_machine + packages = await global_state_machine.list_toolset_packages.remote() + return {"packages": packages} + + +@resource_router.get("/toolset-package/{name}/readme") +async def get_toolset_package_readme( + name: str, + _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)), +): + """返回指定工具包的 README.md 内容(markdown 文本)。""" + global_state_machine = ray_actor_hook("global_state_machine").global_state_machine + content = await global_state_machine.get_toolset_package_readme.remote(name) + if content is None: + raise HTTPException(status_code=404, detail="README not found") + return {"name": name, "content": content} + + # ─── Tool Management ─── @resource_router.get("/tool") @@ -256,7 +328,7 @@ async def _assert_toolset_owner_or_admin( toolset: Dict[str, Any], token_data: TokenData ) -> None: """校验 toolset 归属:非 owner 且非管理员则抛 403。""" - from kilostar.utils.check_user.role_check import get_authority + from kilostar.utils.access import get_authority if toolset.get("owner_id") == token_data.user_id: return @@ -294,7 +366,7 @@ async def list_custom_toolsets( token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)), ): """列出工具组:支持按 category 过滤。USER 只能看到自己的+系统的;ADMIN 看全部。""" - from kilostar.utils.check_user.role_check import get_authority + from kilostar.utils.access import get_authority global_state_machine = ray_actor_hook("global_state_machine").global_state_machine toolsets = await global_state_machine.list_custom_toolsets.remote() diff --git a/kilostar/api/system.py b/kilostar/api/system.py index 1ed2507..5fe0e35 100644 --- a/kilostar/api/system.py +++ b/kilostar/api/system.py @@ -25,8 +25,7 @@ from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from kilostar.utils.ray_hook import ray_actor_hook -from kilostar.utils.access import Accessor, TokenData -from kilostar.utils.check_user.role_check import RoleChecker +from kilostar.utils.access import Accessor, TokenData, RoleChecker from kilostar.core.postgres_database.model import UserAuthority from kilostar.utils.config_loader import ( get_workflow_config, diff --git a/kilostar/api/workflow.py b/kilostar/api/workflow.py index 259a573..35a731a 100644 --- a/kilostar/api/workflow.py +++ b/kilostar/api/workflow.py @@ -18,8 +18,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel from ulid import ULID import asyncio -from kilostar.utils.access import Accessor, TokenData -from kilostar.utils.check_user.role_check import RoleChecker +from kilostar.utils.access import Accessor, TokenData, RoleChecker from kilostar.core.postgres_database.model import UserAuthority workflow_router = APIRouter(prefix="/api/v1/workflow", tags=["workflow"]) diff --git a/kilostar/core/global_state_machine/global_state_machine.py b/kilostar/core/global_state_machine/global_state_machine.py index 7eea9ca..4410c88 100644 --- a/kilostar/core/global_state_machine/global_state_machine.py +++ b/kilostar/core/global_state_machine/global_state_machine.py @@ -13,7 +13,7 @@ # limitations under the License. from typing import Any, Dict, List, Optional, Tuple -from kilostar.utils.standalone_proxy import actor_class, _STANDALONE +from kilostar.utils.ray_compat import actor_class, _STANDALONE if not _STANDALONE: import ray @@ -77,47 +77,40 @@ class GlobalStateMachine: # 启动期一次性发布 v1 快照,让等待中的读端立刻可用 self._publish_snapshot() - _SYSTEM_TOOLSETS = [ - { - "toolset_id": "system_basic", - "name": "系统基础工具集", - "description": "文件读写、搜索、代码执行等基础能力", - "tools": ["file_reader", "write_file", "edit_file", "search_file", "python_executor", "shell_executor"], - "is_system": True, - "category": "system_basic", - }, - { - "toolset_id": "system_chat", - "name": "系统对话工具集", - "description": "对话场景专用工具(发送文件等)", - "tools": ["send_file"], - "is_system": True, - "category": "system_chat", - }, - { - "toolset_id": "system_workflow", - "name": "系统工作流工具集", - "description": "工作流场景专用工具(审批、发送文件等)", - "tools": ["approval", "send_file"], - "is_system": True, - "category": "system_workflow", - }, - ] - async def _seed_system_toolsets(self): - """若 DB 中缺少系统预置工具集则自动补种。""" - for seed in self._SYSTEM_TOOLSETS: - if seed["toolset_id"] not in self._custom_toolsets: - await self.postgres_database.upsert_custom_toolset.remote( - toolset_id=seed["toolset_id"], - name=seed["name"], - tools=seed["tools"], - description=seed["description"], - owner_id=None, - is_system=True, - category=seed["category"], - ) - self._custom_toolsets[seed["toolset_id"]] = seed + """把磁盘上每个 toolset 包同步成一个 system custom_toolset 记录。 + + toolset 包就是插件单元——目录结构 ``data/toolset//`` 即代表一个工具集。 + 启动时把每个包"投影"成一条 ``is_system=True`` 的 custom_toolset, + 前端工具插件界面看到的卡片就是这些包;将来安装第三方插件 = 把目录扔进去。 + + 旧版本写死过 ``system_basic`` / ``system_chat`` / ``system_workflow`` 这种 + 逻辑分组,这里会一并清理掉,避免遗留脏数据。 + """ + packages = self._global_tool_manager.toolset_packages + wanted_ids = {f"system::{name}" for name in packages.keys()} + + # 清理 stale 系统 toolset(包括旧版硬编码的 system_basic/system_chat/...) + for tid, ts in list(self._custom_toolsets.items()): + if not ts.get("is_system"): + continue + if tid in wanted_ids: + continue + await self.postgres_database.delete_custom_toolset.remote(tid) + self._custom_toolsets.pop(tid, None) + + for name, pkg in packages.items(): + tid = f"system::{name}" + saved = await self.postgres_database.upsert_custom_toolset.remote( + toolset_id=tid, + name=pkg.get("display_name") or name, + tools=list(pkg.get("tools", [])), + description=pkg.get("description") or None, + owner_id=None, + is_system=True, + category=name, + ) + self._custom_toolsets[tid] = saved # ─── Snapshot 发布(Object Store 读路径) ──────────────────── @@ -254,6 +247,24 @@ class GlobalStateMachine: """仅返回 retrieval 工具集(system_node 专用,不包含 generation 工具)。""" return self._global_tool_manager.get_retrieval_toolsets_for_scope(scope) + def list_toolset_packages(self) -> List[Dict[str, Any]]: + """列出所有磁盘工具包(前端"工具插件"页面卡片即由此渲染)。""" + return [ + {k: v for k, v in pkg.items() if k != "readme_path"} + for pkg in self._global_tool_manager.toolset_packages.values() + ] + + def get_toolset_package_readme(self, name: str) -> Optional[str]: + """读取指定工具包的 README.md 内容;不存在返回 None。""" + pkg = self._global_tool_manager.toolset_packages.get(name) + if not pkg or not pkg.get("readme_path"): + return None + try: + with open(pkg["readme_path"], "r", encoding="utf-8") as f: + return f.read() + except Exception: + return None + # ─── MCP Server Registry ─────────────────────────────────── async def add_mcp_server(self, server_id: str, config: Dict[str, Any]) -> bool: diff --git a/kilostar/core/global_state_machine/gsm_snapshot.py b/kilostar/core/global_state_machine/gsm_snapshot.py index e04c8b2..ca1eef4 100644 --- a/kilostar/core/global_state_machine/gsm_snapshot.py +++ b/kilostar/core/global_state_machine/gsm_snapshot.py @@ -33,7 +33,7 @@ import asyncio from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Tuple -from kilostar.utils.standalone_proxy import _STANDALONE +from kilostar.utils.ray_compat import _STANDALONE if not _STANDALONE: import ray @@ -63,7 +63,7 @@ class GSMSnapshot: tool_metadata: Dict[str, Dict[str, Any]] = field(default_factory=dict) tool_funcs: Dict[str, Callable[..., Any]] = field(default_factory=dict) third_party_funcs: Dict[str, Callable[..., Any]] = field(default_factory=dict) - tool_mapper: Dict[str, Dict[str, type]] = field(default_factory=dict) + tool_mapper: Dict[str, Dict[str, Callable[..., Any]]] = field(default_factory=dict) # ``{scope: [tool_name, ...]}``:系统工具按 scope 维护的工具名清单。 # 客户端按名字 + ``tool_funcs`` 在自己进程里重建 FunctionToolset, # 避开把不可序列化/版本耦合的 toolset 实例塞进快照的坑。 diff --git a/kilostar/core/global_state_machine/skill_manager.py b/kilostar/core/global_state_machine/skill_manager.py index e41f068..5078144 100644 --- a/kilostar/core/global_state_machine/skill_manager.py +++ b/kilostar/core/global_state_machine/skill_manager.py @@ -17,9 +17,11 @@ from collections import defaultdict import pathlib import json +from kilostar.utils.settings import get_plugin_dir + class GlobalSkillManager: - """Skill 注册表:从 ``kilostar/plugin/skill//skill.json`` 启动期一次性扫描加载。""" + """Skill 注册表:从 ``data/plugin/skill//skill.json`` 启动期一次性扫描加载。""" skill_mapper = Dict[str, Tuple[str]] """skill的存储表""" @@ -27,23 +29,16 @@ class GlobalSkillManager: def __init__(self): self.skill_mapper = defaultdict(tuple) - import os - - skill_plugin_dir = pathlib.Path( - os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "plugin", "skill") - ) - ) + skill_plugin_dir = get_plugin_dir() / "skill" if not skill_plugin_dir.exists() or not skill_plugin_dir.is_dir(): return for item in skill_plugin_dir.iterdir(): if item.is_dir() and not item.name.startswith((".", "__")): - json_path = item / "skill.json" # 拼接文件路径 + json_path = item / "skill.json" if json_path.exists(): try: with open(json_path, "r", encoding="utf-8") as f: skill = json.load(f) - # 提取并映射 name = skill.get("name") if name: self.skill_mapper[name] = ( @@ -55,13 +50,7 @@ class GlobalSkillManager: def add_skill(self, skill_name: str) -> None: """Add a skill to the manager by reading its skill.json from the path""" - import os - - skill_plugin_dir = pathlib.Path( - os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "plugin", "skill") - ) - ) + skill_plugin_dir = get_plugin_dir() / "skill" item = skill_plugin_dir / skill_name if item.is_dir() and not item.name.startswith((".", "__")): json_path = item / "skill.json" diff --git a/kilostar/core/global_state_machine/tool_manager.py b/kilostar/core/global_state_machine/tool_manager.py index ddfa8da..cde2ff4 100644 --- a/kilostar/core/global_state_machine/tool_manager.py +++ b/kilostar/core/global_state_machine/tool_manager.py @@ -1,27 +1,39 @@ -import pathlib -import importlib -import inspect +import json +import importlib.util +import sys +import types from collections import defaultdict -from typing import Any, Callable, Dict, List, Type +from typing import Any, Callable, Dict, List -from kilostar.plugin.tool_plugin.base_tool import BaseToolData +from kilostar.utils.settings import get_toolset_dir from kilostar.utils.logger import get_logger logger = get_logger("tool_manager") -_SYSTEM_BUCKET = "system" + +def _bootstrap_toolset_modules(): + """在 sys.modules 中注册 toolset 的虚拟包层级,使 toolset 内部的相对 import 能正常工作。""" + toolset_dir = get_toolset_dir() + + for pkg_name, pkg_path in [ + ("data", toolset_dir.parent), + ("data.toolset", toolset_dir), + ]: + if pkg_name not in sys.modules: + mod = types.ModuleType(pkg_name) + mod.__path__ = [str(pkg_path)] + mod.__package__ = pkg_name + sys.modules[pkg_name] = mod class GlobalToolManager: - """工具注册表:扫描 ``kilostar/plugin/tool_plugin/`` 下所有 BaseToolData 子类, - 按 ``action_scope`` 打包成 ``FunctionToolset``。 + """工具注册表:扫描 ``data/toolset/`` 下所有 toolset 的 manifest.json, + 按 ``action_scope`` / ``is_system`` / ``category`` 分桶。 三类 toolset: - **system**:``is_system=True`` 的工具,按 scope 分组 - **custom**:用户自定义工具组(由 ``rebuild_custom_toolsets`` 动态构建) - **mcp**:由 ``mcp_helper`` 独立管理,不经过本类 - - ``category="mcp"`` 的工具不会被本类管理。 """ tool_metadata: Dict[str, Dict[str, Any]] @@ -30,7 +42,7 @@ class GlobalToolManager: _custom_toolsets: Dict[str, Any] _third_party_funcs: Dict[str, Callable] _all_funcs: Dict[str, Callable] - tool_mapper: Dict[str, Dict[str, Type[BaseToolData]]] + toolset_packages: Dict[str, Dict[str, Any]] def __init__(self) -> None: self.tool_metadata = {} @@ -41,75 +53,126 @@ class GlobalToolManager: self._custom_toolsets = {} self._third_party_funcs = {} self._all_funcs = {} - self.tool_mapper = defaultdict(dict) + self.toolset_packages = {} - tool_plugin_dir = ( - pathlib.Path(__file__).parent.parent.parent / "plugin" / "tool_plugin" - ) - if not tool_plugin_dir.exists() or not tool_plugin_dir.is_dir(): + _bootstrap_toolset_modules() + + toolset_root = get_toolset_dir() + if not toolset_root.exists() or not toolset_root.is_dir(): return - for item in tool_plugin_dir.iterdir(): - if not (item.is_dir() and not item.name.startswith("__")): + for toolset_dir in toolset_root.iterdir(): + if not toolset_dir.is_dir() or toolset_dir.name.startswith("__"): continue - plugin_name = item.name - module_name = f"kilostar.plugin.tool_plugin.{plugin_name}" + manifest_path = toolset_dir / "manifest.json" + if not manifest_path.exists(): + continue + self._load_toolset(toolset_dir, manifest_path) - try: - module = importlib.import_module(module_name) - except Exception as e: - logger.warning(f"Failed to import tool plugin {plugin_name}: {e}") + self._build_system_toolsets() + self._build_retrieval_toolsets() + + def _load_toolset(self, toolset_dir, manifest_path) -> None: + """从一个 toolset 目录加载 manifest 并注册所有工具。""" + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + except Exception as e: + logger.warning(f"Failed to read manifest {manifest_path}: {e}") + return + + toolset_name = toolset_dir.name + + # 注册 toolset 的虚拟包 + pkg_name = f"data.toolset.{toolset_name}" + if pkg_name not in sys.modules: + mod = types.ModuleType(pkg_name) + mod.__path__ = [str(toolset_dir)] + mod.__package__ = pkg_name + sys.modules[pkg_name] = mod + + registered_tools: List[str] = [] + + for tool_def in manifest.get("tools", []): + tool_name = tool_def.get("name") + tool_file = tool_def.get("file", f"{tool_name}.py") + if not tool_name: continue - tool_data_cls = self._find_tool_data_class(module) - if tool_data_cls is None: + file_path = toolset_dir / tool_file + if not file_path.exists(): + logger.warning(f"Tool file not found: {file_path}") continue - tool_func = getattr(module, plugin_name, None) - if not callable(tool_func): - logger.warning( - f"Tool plugin '{plugin_name}' has no callable named " - f"'{plugin_name}' in its module; skipped." - ) + tool_func = self._load_tool_func(toolset_name, tool_name, file_path) + if tool_func is None: continue - action_scopes = ( - tool_data_cls.model_fields.get("action_scope").default or [] - ) - is_system = bool(tool_data_cls.model_fields.get("is_system").default) - category_field = tool_data_cls.model_fields.get("category") - category = (category_field.default if category_field else "other") or "other" - toolset_field = tool_data_cls.model_fields.get("toolset") - toolset_name = (toolset_field.default if toolset_field else "other") or "other" + is_system = tool_def.get("is_system", True) + action_scopes = tool_def.get("action_scope", []) + category = tool_def.get("category", "other") + config_args = tool_def.get("config_args", {}) + toolset_field = tool_def.get("toolset", "other") - self.tool_metadata[plugin_name] = { - "name": plugin_name, + self.tool_metadata[tool_name] = { + "name": tool_name, "is_system": is_system, "category": category, - "toolset": toolset_name, + "toolset": toolset_field, "action_scope": list(action_scopes), + "config_args": config_args, + "source_toolset": toolset_name, } + registered_tools.append(tool_name) if category == "mcp": continue - self._all_funcs[plugin_name] = tool_func + self._all_funcs[tool_name] = tool_func scopes = [s for s in action_scopes if s] or ["default"] if is_system: for scope in scopes: - self._tool_funcs[scope][plugin_name] = tool_func - self.tool_mapper[scope][plugin_name] = tool_data_cls - if toolset_name == "retrieval": - self._retrieval_tool_funcs[scope][plugin_name] = tool_func + self._tool_funcs[scope][tool_name] = tool_func + if toolset_field == "retrieval": + self._retrieval_tool_funcs[scope][tool_name] = tool_func else: - self._third_party_funcs[plugin_name] = tool_func - for scope in scopes: - self.tool_mapper[scope][plugin_name] = tool_data_cls + self._third_party_funcs[tool_name] = tool_func - self._build_system_toolsets() - self._build_retrieval_toolsets() + readme_path = toolset_dir / "README.md" + self.toolset_packages[toolset_name] = { + "name": toolset_name, + "display_name": manifest.get("name", toolset_name), + "version": manifest.get("version", ""), + "description": manifest.get("description", ""), + "tools": registered_tools, + "has_readme": readme_path.exists(), + "readme_path": str(readme_path) if readme_path.exists() else None, + } + + def _load_tool_func(self, toolset_name: str, tool_name: str, file_path) -> Callable | None: + """从文件加载工具函数。""" + module_name = f"data.toolset.{toolset_name}.{tool_name}" + try: + spec = importlib.util.spec_from_file_location(module_name, str(file_path)) + if spec is None or spec.loader is None: + logger.warning(f"Failed to create spec for {module_name}") + return None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + func = getattr(module, tool_name, None) + if not callable(func): + logger.warning( + f"Tool '{tool_name}' has no callable named '{tool_name}' in {file_path}" + ) + return None + return func + except Exception as e: + logger.warning(f"Failed to import tool {tool_name}: {e}") + return None def _build_system_toolsets(self) -> None: FunctionToolset = self._import_function_toolset() @@ -142,11 +205,7 @@ class GlobalToolManager: logger.error(f"Failed to build retrieval toolset {scope}: {e}") def rebuild_custom_toolsets(self, custom_defs: Dict[str, Dict[str, Any]]) -> None: - """根据 DB 中的 toolset 定义重建 FunctionToolset。 - - 系统 toolset(is_system=True)允许包含 system 工具,用户 toolset 只取得到 callable - 的工具(理论上业务层已校验只包含第三方工具)。 - """ + """根据 DB 中的 toolset 定义重建 FunctionToolset。""" FunctionToolset = self._import_function_toolset() if FunctionToolset is None: self._custom_toolsets = {} @@ -178,15 +237,13 @@ class GlobalToolManager: logger.warning("pydantic_ai.toolsets unavailable") return None - @staticmethod - def _find_tool_data_class(module) -> Type[BaseToolData] | None: - for _, obj in inspect.getmembers(module, inspect.isclass): - if issubclass(obj, BaseToolData) and obj is not BaseToolData: - return obj - return None - # ─── Toolset accessors ─── + @property + def tool_mapper(self) -> Dict[str, Dict[str, Callable]]: + """scope → {tool_name: callable},兼容 GSM 快照构建。""" + return dict(self._tool_funcs) + def get_system_toolset(self, scope: str) -> Any | None: return self._system_toolsets.get(scope) @@ -230,7 +287,6 @@ class GlobalToolManager: def get_all_tools(self) -> List[Dict[str, Any]]: return list(self.tool_metadata.values()) - # 兼容旧接口 def get_non_system_tools(self) -> List[Dict[str, Any]]: return self.get_third_party_tools() diff --git a/kilostar/core/global_workflow_manager/global_workflow_manager.py b/kilostar/core/global_workflow_manager/global_workflow_manager.py index 6d4bc8c..bddf9c6 100644 --- a/kilostar/core/global_workflow_manager/global_workflow_manager.py +++ b/kilostar/core/global_workflow_manager/global_workflow_manager.py @@ -1,6 +1,6 @@ import asyncio from typing import Dict -from kilostar.utils.standalone_proxy import actor_class +from kilostar.utils.ray_compat import actor_class from kilostar.utils.ray_hook import ray_actor_hook from kilostar.utils.logger import get_logger diff --git a/kilostar/core/individual/consciousness_node/consciousness_node.py b/kilostar/core/individual/consciousness_node/consciousness_node.py index 8ff709f..7bfb557 100644 --- a/kilostar/core/individual/consciousness_node/consciousness_node.py +++ b/kilostar/core/individual/consciousness_node/consciousness_node.py @@ -14,7 +14,7 @@ from typing import Union, overload -from kilostar.utils.standalone_proxy import actor_class +from kilostar.utils.ray_compat import actor_class from kilostar.core.individual.consciousness_node.template import ( ConsciousnessNodeDeps, ForregulatoryNode, @@ -29,7 +29,7 @@ from kilostar.core.global_state_machine.global_state_machine import GlobalStateM from kilostar.core.global_state_machine.model_provider.base_provider import Provider from kilostar.adapter.model_adapter.agent_factory import AgentFactory from kilostar.utils.ray_hook import ray_actor_hook -from kilostar.utils.i18n import agent_prompt +from kilostar.utils.prompts import agent_prompt @actor_class diff --git a/kilostar/core/individual/control_node/control_node.py b/kilostar/core/individual/control_node/control_node.py index 7bead70..2254895 100644 --- a/kilostar/core/individual/control_node/control_node.py +++ b/kilostar/core/individual/control_node/control_node.py @@ -13,7 +13,7 @@ # limitations under the License. from pydantic_ai import Agent, RunContext -from kilostar.utils.standalone_proxy import actor_class +from kilostar.utils.ray_compat import actor_class from kilostar.core.global_state_machine.global_state_machine import GlobalStateMachine from kilostar.core.global_state_machine.model_provider.base_provider import Provider from kilostar.adapter.model_adapter.agent_factory import AgentFactory @@ -22,7 +22,7 @@ from kilostar.core.individual.control_node.template import ( ForWorkflowInput, ControlNodeDeps, ) -from kilostar.utils.i18n import agent_prompt +from kilostar.utils.prompts import agent_prompt @actor_class diff --git a/kilostar/core/individual/regulatory_node/regulatory_node.py b/kilostar/core/individual/regulatory_node/regulatory_node.py index bc6efc9..410014b 100644 --- a/kilostar/core/individual/regulatory_node/regulatory_node.py +++ b/kilostar/core/individual/regulatory_node/regulatory_node.py @@ -15,7 +15,7 @@ import asyncio import datetime from typing import Union -from kilostar.utils.standalone_proxy import actor_class +from kilostar.utils.ray_compat import actor_class from kilostar.adapter.model_adapter.agent_factory import AgentFactory from kilostar.core.global_state_machine.global_state_machine import GlobalStateMachine from kilostar.core.global_state_machine.model_provider import Provider @@ -25,7 +25,7 @@ from kilostar.core.individual.regulatory_node.template import ( MessageResponse ) from pydantic_ai import RunContext, Agent -from kilostar.utils.i18n import agent_prompt +from kilostar.utils.prompts import agent_prompt @actor_class @@ -111,15 +111,20 @@ class RegulatoryNode: ) return prompt - async def working(self, payload: MessageRequest) -> Union[MessageResponse, None]: + async def working( + self, + payload: MessageRequest, + message_history: list | None = None, + ) -> Union[MessageResponse, None]: """working方法,是节点唯一的调用方法,对_run函数的结果进行判断并返回最终回复 Args: payload: 消息载荷,包含所有信息 + message_history: pydantic-ai ``ModelMessage`` 列表,传入历史让多轮对话连贯 Returns: MessageResponse 或 None,监控节点对用户的结构化回复 """ - return await self._run(payload) + return await self._run(payload, message_history=message_history) _CHAT_INSTRUCTIONS = ( "你是 kilostar 智能助手。你现在处于【直接对话模式】,请直接回答用户的问题。\n" @@ -130,7 +135,12 @@ class RegulatoryNode: "4. 回复应当完整、有帮助,避免过于简短。\n" ) - async def stream_working(self, payload: MessageRequest, token_queue: "asyncio.Queue") -> None: + async def stream_working( + self, + payload: MessageRequest, + token_queue: "asyncio.Queue", + message_history: list | None = None, + ) -> None: """流式对话:完整执行 agent graph(含工具调用),逐 token 推送文本到 queue。 使用 event_stream_handler 回调拿到每个 text delta,保证工具调用后 @@ -167,6 +177,7 @@ class RegulatoryNode: output_type=str, instructions=self._CHAT_INSTRUCTIONS, event_stream_handler=_stream_handler, + message_history=message_history, ) except Exception as e: self.logger.exception(f"RegulatoryNode.stream_working failed: {e}") @@ -175,7 +186,9 @@ class RegulatoryNode: await token_queue.put(None) async def _run( - self, payload: MessageRequest + self, + payload: MessageRequest, + message_history: list | None = None, ) -> Union[MessageResponse, None]: platform = payload.platform user_name = payload.user_name @@ -187,8 +200,11 @@ class RegulatoryNode: user_name=user_name, time=time_str ) - agent_response = await self.agent.run(user_prompt=message, - deps=deps,) + agent_response = await self.agent.run( + user_prompt=message, + deps=deps, + message_history=message_history, + ) response: MessageResponse = agent_response.output response.platform = platform response.platform_id = payload.platform_id diff --git a/kilostar/core/postgres_database/model/__init__.py b/kilostar/core/postgres_database/model/__init__.py index 4e7fe03..6f15330 100644 --- a/kilostar/core/postgres_database/model/__init__.py +++ b/kilostar/core/postgres_database/model/__init__.py @@ -35,6 +35,8 @@ from kilostar.core.postgres_database.model.tool_config import ToolConfigModel from kilostar.core.postgres_database.model.custom_toolset import CustomToolsetModel from kilostar.core.postgres_database.model.system_event_log import SystemEventLog from kilostar.core.postgres_database.model.persona_template import PersonaTemplate +from kilostar.core.postgres_database.model.org_task import OrgTask +from kilostar.core.postgres_database.model.org_task_event import OrgTaskEvent # 兼容旧代码的别名 Provider = ProviderModel @@ -65,5 +67,7 @@ __all__ = [ "CustomToolsetModel", "SystemEventLog", "PersonaTemplate", + "OrgTask", + "OrgTaskEvent", "AgentType", ] diff --git a/kilostar/core/postgres_database/model/org_task.py b/kilostar/core/postgres_database/model/org_task.py new file mode 100644 index 0000000..095b703 --- /dev/null +++ b/kilostar/core/postgres_database/model/org_task.py @@ -0,0 +1,38 @@ +from sqlalchemy import String, DateTime, Integer, func, Text +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import JSONB +from .base import BaseDataModel + + +class OrgTask(BaseDataModel): + __tablename__ = "org_task" + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + task_id: Mapped[str] = mapped_column( + String(64), unique=True, index=True, comment="外部任务 ID(UUID)" + ) + org_name: Mapped[str] = mapped_column( + String(128), index=True, comment="所属组织/插件名" + ) + status: Mapped[str] = mapped_column( + String(20), index=True, default="pending", + comment="pending/running/done/error" + ) + description: Mapped[str] = mapped_column( + Text, comment="任务描述" + ) + result: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="最终结果" + ) + context: Mapped[dict | None] = mapped_column( + JSONB, nullable=True, comment="调用上下文" + ) + created_at: Mapped[str] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + updated_at: Mapped[str] = mapped_column( + DateTime(timezone=True), server_default=func.now(), + onupdate=func.now() + ) diff --git a/kilostar/core/postgres_database/model/org_task_event.py b/kilostar/core/postgres_database/model/org_task_event.py new file mode 100644 index 0000000..5681674 --- /dev/null +++ b/kilostar/core/postgres_database/model/org_task_event.py @@ -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 + ) diff --git a/kilostar/core/postgres_database/module/org_task.py b/kilostar/core/postgres_database/module/org_task.py new file mode 100644 index 0000000..bcc0841 --- /dev/null +++ b/kilostar/core/postgres_database/module/org_task.py @@ -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 + ] diff --git a/kilostar/core/postgres_database/postgres.py b/kilostar/core/postgres_database/postgres.py index a71ee01..6918b8c 100644 --- a/kilostar/core/postgres_database/postgres.py +++ b/kilostar/core/postgres_database/postgres.py @@ -15,7 +15,7 @@ import os import asyncio -from kilostar.utils.standalone_proxy import actor_class +from kilostar.utils.ray_compat import actor_class from kilostar.utils.settings import get_settings from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker @@ -44,6 +44,8 @@ from kilostar.core.postgres_database.model.tool_config import ToolConfigModel from kilostar.core.postgres_database.model.custom_toolset import CustomToolsetModel from kilostar.core.postgres_database.model.system_event_log import SystemEventLog from kilostar.core.postgres_database.model.persona_template import PersonaTemplate +from kilostar.core.postgres_database.model.org_task import OrgTask +from kilostar.core.postgres_database.model.org_task_event import OrgTaskEvent from .module.individual import IndividualDatabase from .module.user import AuthDatabase @@ -56,6 +58,7 @@ from .module.tool_config import ToolConfigDatabase from .module.custom_toolset import CustomToolsetDatabase from .module.system_event_log import SystemEventLogDatabase from .module.persona_template import PersonaTemplateDatabase +from .module.org_task import OrgTaskDatabase @actor_class @@ -89,6 +92,7 @@ class PostgresDatabase: self._custom_toolset_database = CustomToolsetDatabase(self.async_session_maker) self._system_event_log_database = SystemEventLogDatabase(self.async_session_maker) self._persona_template_database = PersonaTemplateDatabase(self.async_session_maker) + self._org_task_database = OrgTaskDatabase(self.async_session_maker) self.ready_event = asyncio.Event() @@ -458,3 +462,28 @@ class PostgresDatabase: async def delete_template(self, template_id: str): await self.ready_event.wait() return await self._persona_template_database.delete_template(template_id) + + # Org Task Database Methods + async def create_org_task(self, task_id: str, org_name: str, description: str, context=None): + await self.ready_event.wait() + return await self._org_task_database.create_task(task_id, org_name, description, context) + + async def update_org_task_status(self, task_id: str, status: str, result=None): + await self.ready_event.wait() + return await self._org_task_database.update_status(task_id, status, result) + + async def get_org_task(self, task_id: str): + await self.ready_event.wait() + return await self._org_task_database.get_task(task_id) + + async def list_org_tasks(self, org_name=None, limit=50, offset=0): + await self.ready_event.wait() + return await self._org_task_database.list_tasks(org_name, limit, offset) + + async def insert_org_event(self, task_id: str, event_type: str, payload=None): + await self.ready_event.wait() + return await self._org_task_database.insert_event(task_id, event_type, payload) + + async def query_org_events(self, task_id: str, limit=200): + await self.ready_event.wait() + return await self._org_task_database.query_events(task_id, limit) diff --git a/kilostar/core/work/workflow/workflow_engine.py b/kilostar/core/work/workflow/workflow_engine.py index 551206e..e26b8c3 100644 --- a/kilostar/core/work/workflow/workflow_engine.py +++ b/kilostar/core/work/workflow/workflow_engine.py @@ -36,7 +36,7 @@ import datetime from dataclasses import dataclass from typing import Any, Awaitable, Callable, Dict, List, Optional -from kilostar.utils.standalone_proxy import remote_task, _STANDALONE +from kilostar.utils.ray_compat import remote_task, _STANDALONE from pydantic import BaseModel, Field from pydantic_graph import BaseNode, End, Graph, GraphRunContext from pydantic_graph.persistence import BaseStatePersistence diff --git a/kilostar/plugin/__init__.py b/kilostar/plugin/__init__.py deleted file mode 100644 index 319b42c..0000000 --- a/kilostar/plugin/__init__.py +++ /dev/null @@ -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. diff --git a/kilostar/plugin/tool_plugin/__init__.py b/kilostar/plugin/tool_plugin/__init__.py deleted file mode 100644 index 319b42c..0000000 --- a/kilostar/plugin/tool_plugin/__init__.py +++ /dev/null @@ -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. diff --git a/kilostar/plugin/tool_plugin/approval/__init__.py b/kilostar/plugin/tool_plugin/approval/__init__.py deleted file mode 100644 index 5440cdc..0000000 --- a/kilostar/plugin/tool_plugin/approval/__init__.py +++ /dev/null @@ -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"] diff --git a/kilostar/plugin/tool_plugin/approval/approval.py b/kilostar/plugin/tool_plugin/approval/approval.py deleted file mode 100644 index 105964c..0000000 --- a/kilostar/plugin/tool_plugin/approval/approval.py +++ /dev/null @@ -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 diff --git a/kilostar/plugin/tool_plugin/approval/config.json b/kilostar/plugin/tool_plugin/approval/config.json deleted file mode 100644 index 7a73a41..0000000 --- a/kilostar/plugin/tool_plugin/approval/config.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} \ No newline at end of file diff --git a/kilostar/plugin/tool_plugin/base_tool.py b/kilostar/plugin/tool_plugin/base_tool.py deleted file mode 100644 index 3107ede..0000000 --- a/kilostar/plugin/tool_plugin/base_tool.py +++ /dev/null @@ -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 集。""" diff --git a/kilostar/plugin/tool_plugin/edit_file/__init__.py b/kilostar/plugin/tool_plugin/edit_file/__init__.py deleted file mode 100644 index 247dd89..0000000 --- a/kilostar/plugin/tool_plugin/edit_file/__init__.py +++ /dev/null @@ -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}" diff --git a/kilostar/plugin/tool_plugin/file_reader/__init__.py b/kilostar/plugin/tool_plugin/file_reader/__init__.py deleted file mode 100644 index 3da125f..0000000 --- a/kilostar/plugin/tool_plugin/file_reader/__init__.py +++ /dev/null @@ -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"] diff --git a/kilostar/plugin/tool_plugin/file_reader/file_reader.py b/kilostar/plugin/tool_plugin/file_reader/file_reader.py deleted file mode 100644 index 65cf3c1..0000000 --- a/kilostar/plugin/tool_plugin/file_reader/file_reader.py +++ /dev/null @@ -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)}" diff --git a/kilostar/plugin/tool_plugin/python_executor/__init__.py b/kilostar/plugin/tool_plugin/python_executor/__init__.py deleted file mode 100644 index 6ec9449..0000000 --- a/kilostar/plugin/tool_plugin/python_executor/__init__.py +++ /dev/null @@ -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) diff --git a/kilostar/plugin/tool_plugin/search_file/__init__.py b/kilostar/plugin/tool_plugin/search_file/__init__.py deleted file mode 100644 index 6b5bfa0..0000000 --- a/kilostar/plugin/tool_plugin/search_file/__init__.py +++ /dev/null @@ -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}" diff --git a/kilostar/plugin/tool_plugin/send_file/__init__.py b/kilostar/plugin/tool_plugin/send_file/__init__.py deleted file mode 100644 index bf8a177..0000000 --- a/kilostar/plugin/tool_plugin/send_file/__init__.py +++ /dev/null @@ -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"] diff --git a/kilostar/plugin/tool_plugin/send_file/send_file.py b/kilostar/plugin/tool_plugin/send_file/send_file.py deleted file mode 100644 index e343b02..0000000 --- a/kilostar/plugin/tool_plugin/send_file/send_file.py +++ /dev/null @@ -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}" diff --git a/kilostar/plugin/tool_plugin/shell_executor/__init__.py b/kilostar/plugin/tool_plugin/shell_executor/__init__.py deleted file mode 100644 index 5f802e3..0000000 --- a/kilostar/plugin/tool_plugin/shell_executor/__init__.py +++ /dev/null @@ -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}" diff --git a/kilostar/plugin/tool_plugin/tavily_search/__init__.py b/kilostar/plugin/tool_plugin/tavily_search/__init__.py deleted file mode 100644 index a77e64c..0000000 --- a/kilostar/plugin/tool_plugin/tavily_search/__init__.py +++ /dev/null @@ -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)}" diff --git a/kilostar/plugin/tool_plugin/write_file/__init__.py b/kilostar/plugin/tool_plugin/write_file/__init__.py deleted file mode 100644 index 0400705..0000000 --- a/kilostar/plugin/tool_plugin/write_file/__init__.py +++ /dev/null @@ -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}" diff --git a/kilostar/plugin_runtime/__init__.py b/kilostar/plugin_runtime/__init__.py new file mode 100644 index 0000000..59d0003 --- /dev/null +++ b/kilostar/plugin_runtime/__init__.py @@ -0,0 +1,29 @@ +"""KiloStar 重型插件(Organization)运行时。 + +每个重型插件 = 一个组织/部门: +- ``data/plugin//`` 目录约定 +- 内部多个平级专家 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", +] diff --git a/kilostar/plugin_runtime/agents_config.py b/kilostar/plugin_runtime/agents_config.py new file mode 100644 index 0000000..f710923 --- /dev/null +++ b/kilostar/plugin_runtime/agents_config.py @@ -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 diff --git a/kilostar/plugin_runtime/base_organization.py b/kilostar/plugin_runtime/base_organization.py new file mode 100644 index 0000000..fca201e --- /dev/null +++ b/kilostar/plugin_runtime/base_organization.py @@ -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/// + artifact_dir = str(get_artifact_dir() / trace_id / self.name) + + ts = TaskState( + task_id=task_id, + org_name=self.name, + trace_id=trace_id, + user_id=user_id, + description=task_description, + source=source, # type: ignore[arg-type] + ) + self._tasks[task_id] = ts + self._futures[task_id] = asyncio.get_event_loop().create_future() + + full_ctx = { + **ctx, + "trace_id": trace_id, + "user_id": user_id, + "task_id": task_id, + "source": source, + "artifact_dir": artifact_dir, + } + await self._queue.put((task_id, task_description, full_ctx)) + + # 持久化(best-effort,PG 不可用时静默) + await self._persist_task(ts) + return task_id + + async def _consume_queue(self) -> None: + while not self._stopped: + try: + task_id, desc, ctx = await self._queue.get() + except asyncio.CancelledError: + break + try: + await self._run_task(task_id, desc, ctx) + except Exception as e: + self.logger.exception(f"task {task_id} crashed: {e}") + + async def _run_task(self, task_id: str, desc: str, ctx: Dict[str, Any]) -> None: + ts = self._tasks[task_id] + ts.status = "running" + await self._persist_task(ts) + + async def _emit(ev: OrgEvent) -> None: + ts.events.append(ev) + sub = self._streams.get(task_id) + if sub is not None: + await sub.put(ev) + await self._persist_event(ts, ev) + + try: + result = await self.react(desc, ctx, _emit) + ts.status = "completed" + ts.result = result + await _emit( + OrgEvent(task_id=task_id, type="done", payload={"result": result}) + ) + except Exception as e: + ts.status = "failed" + ts.error = str(e) + await _emit( + OrgEvent(task_id=task_id, type="error", payload={"error": str(e)}) + ) + finally: + await self._persist_task(ts) + # 通知 stream 关闭 + sub = self._streams.get(task_id) + if sub is not None: + await sub.put(None) + # 唤醒 dispatch 端 + fut = self._futures.get(task_id) + if fut is not None and not fut.done(): + fut.set_result( + { + "task_id": task_id, + "status": ts.status, + "result": ts.result, + "error": ts.error, + } + ) + + # ─── PG 持久化 ───────────────────────────────────────────── + + async def _persist_task(self, ts: TaskState) -> None: + """把任务状态写到 PG。失败不阻塞执行。""" + try: + from kilostar.utils.ray_hook import ray_actor_hook + + pg = ray_actor_hook("postgres_database").postgres_database + await pg.upsert_org_task.remote( + task_id=ts.task_id, + org_name=ts.org_name, + trace_id=ts.trace_id, + user_id=ts.user_id, + status=ts.status, + description=ts.description, + source=ts.source, + result=ts.result if isinstance(ts.result, (str, dict, list, type(None))) else str(ts.result), + error=ts.error, + ) + except Exception: + self.logger.debug("persist_task skipped (no DB / not ready)") + + async def _persist_event(self, ts: TaskState, ev: OrgEvent) -> None: + try: + from kilostar.utils.ray_hook import ray_actor_hook + + pg = ray_actor_hook("postgres_database").postgres_database + await pg.append_org_task_event.remote( + task_id=ts.task_id, event=ev.to_dict() + ) + except Exception: + self.logger.debug("persist_event skipped") + + # ─── 资源加载 ────────────────────────────────────────────── + + async def _load_local_tools(self) -> None: + """加载本组织 toolset/ 目录下的工具。 + + 复用 ``GlobalToolManager`` 的逻辑:扫描 manifest.json,按 name 注入函数表。 + 全局工具白名单(``python_executor`` 等)也合并进来,给 agent 兜底。 + """ + from pathlib import Path + import importlib.util + import sys + + toolset_dir = Path(self.plugin_dir) / "toolset" + if toolset_dir.exists() and (toolset_dir / "manifest.json").exists(): + with open(toolset_dir / "manifest.json", "r", encoding="utf-8") as f: + manifest = json.load(f) + for tool_def in manifest.get("tools", []): + tname = tool_def.get("name") + tfile = tool_def.get("file", f"{tname}.py") + if not tname: + continue + fpath = toolset_dir / tfile + if not fpath.exists(): + self.logger.warning(f"tool file not found: {fpath}") + continue + module_name = f"data.plugin.{self.name}.toolset.{tname}" + spec = importlib.util.spec_from_file_location(module_name, str(fpath)) + if spec is None or spec.loader is None: + continue + mod = importlib.util.module_from_spec(spec) + sys.modules[module_name] = mod + spec.loader.exec_module(mod) + func = getattr(mod, tname, None) + if callable(func): + self._tools_by_name[tname] = func + + # 从全局 tool manager 借通用工具 + await self._merge_global_tools() + + async def _merge_global_tools(self) -> None: + """合并 cabinet 全局工具白名单(python_executor 等基础工具)。""" + try: + from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot + + snapshot = await fetch_snapshot() + for name, func in snapshot.all_funcs.items(): + self._tools_by_name.setdefault(name, func) + except Exception: + self.logger.debug("global tools not available; org runs with local only") + + async def _build_agents(self) -> None: + """按 agents.json 实例化 pydantic-ai Agent。 + + 每个 agent 注入: + - 自己声明的 tools(从 ``_tools_by_name`` 取) + - 一个特殊 ``consult`` 工具(如果 peers 非空),用于跨 agent 协作 + """ + from kilostar.adapter.model_adapter.agent_factory import AgentFactory + from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot + + snapshot = await fetch_snapshot() + factory = AgentFactory() + + for adef in self.agents_config.agents: + provider = snapshot.providers.get(adef.model.provider_title) + if provider is None: + self.logger.warning( + f"provider {adef.model.provider_title!r} not found; agent {adef.name} skipped" + ) + continue + tools = [ + self._tools_by_name[t] + for t in adef.tools + if t in self._tools_by_name + ] + consult_tool = self._make_consult_tool(adef) + if consult_tool is not None: + tools.append(consult_tool) + + try: + agent = factory.create_agent( + provider=provider, + model_id=adef.model.model_id, + output_type=str, + system_prompt=adef.system_prompt or f"You are {adef.role}.", + deps_type=type(None), + agent_name=f"{self.name}.{adef.name}", + tools=tools, + toolsets=None, + ) + self._agents[adef.name] = agent + except Exception as e: + self.logger.warning(f"build agent {adef.name} failed: {e}") + + def _make_consult_tool(self, adef: AgentDef): + """为 agent 生成一个 ``consult(peer, question)`` 工具。 + + peers 为空则不生成;调用时直接 await 同事 agent.run。 + """ + if not adef.peers: + return None + peers = list(adef.peers) + org = self + + async def consult(peer: str, question: str) -> str: + """向同事 agent 提问以获取专业意见。 + + Args: + peer: 同事 agent 名字 + question: 要问的问题 + """ + if peer not in peers: + return f"[error] {peer} 不在你的协作列表中: {peers}" + target = org._agents.get(peer) + if target is None: + return f"[error] 同事 agent {peer} 未启动" + try: + resp = await target.run(user_prompt=question) + return getattr(resp, "output", None) or str(resp) + except Exception as e: + return f"[error] {peer} 失败: {e}" + + return consult diff --git a/kilostar/plugin_runtime/event.py b/kilostar/plugin_runtime/event.py new file mode 100644 index 0000000..6621609 --- /dev/null +++ b/kilostar/plugin_runtime/event.py @@ -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 diff --git a/kilostar/plugin_runtime/loader.py b/kilostar/plugin_runtime/loader.py new file mode 100644 index 0000000..2f02a17 --- /dev/null +++ b/kilostar/plugin_runtime/loader.py @@ -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 ':'") + 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}") diff --git a/kilostar/plugin_runtime/manifest.py b/kilostar/plugin_runtime/manifest.py new file mode 100644 index 0000000..7bfa125 --- /dev/null +++ b/kilostar/plugin_runtime/manifest.py @@ -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_``)。 + 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}" diff --git a/kilostar/plugin_runtime/plugin_manager.py b/kilostar/plugin_runtime/plugin_manager.py new file mode 100644 index 0000000..7b2a0d4 --- /dev/null +++ b/kilostar/plugin_runtime/plugin_manager.py @@ -0,0 +1,137 @@ +"""GlobalPluginManager:重型插件统一管理 actor。 + +职责: +- 启动期扫描 ``data/plugin/`` 下所有组织,依次 setup +- 运行期提供 install / uninstall / reload 三个热装接口 +- 把每个组织注册为 cabinet tool + 挂 FastAPI router +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from kilostar.plugin_runtime.loader import ( + discover_plugins, + install_dependencies, + load_plugin, +) +from kilostar.plugin_runtime.manifest import OrgManifest +from kilostar.plugin_runtime.tool_bridge import make_dispatch_tool +from kilostar.utils.logger import get_logger +from kilostar.utils.ray_compat import _STANDALONE, actor_class +from kilostar.utils.ray_hook import register_standalone +from kilostar.utils.settings import get_plugin_dir + +logger = get_logger("plugin_manager") + + +@actor_class +class GlobalPluginManager: + """单机模式下是对象,分布式下是 ray actor。 + + 每个 loaded 组织保存其 manifest 和 actor handle(standalone=proxy,dist=ray handle)。 + """ + + def __init__(self): + self._orgs: Dict[str, Dict[str, Any]] = {} + self._dispatch_tools: Dict[str, Any] = {} + + async def bootstrap(self) -> None: + """启动期一次性扫描并加载所有插件。""" + plugin_root = get_plugin_dir() + plugin_dirs = discover_plugins(plugin_root) + for plugin_dir in plugin_dirs: + try: + await self._install_from_path(plugin_dir) + except Exception as e: + logger.error(f"bootstrap: failed to load plugin {plugin_dir.name}: {e}") + + # ─── 热装载接口 ───────────────────────────────────────────── + + async def install(self, name: str) -> Dict[str, Any]: + """热装载一个插件(按目录名)。""" + plugin_dir = get_plugin_dir() / name + if not plugin_dir.exists(): + raise FileNotFoundError(f"plugin dir not found: {plugin_dir}") + if name in self._orgs: + await self.uninstall(name) + await self._install_from_path(plugin_dir) + return {"name": name, "status": "installed"} + + async def uninstall(self, name: str) -> Dict[str, Any]: + """卸载一个插件。""" + org_info = self._orgs.pop(name, None) + if org_info is None: + return {"name": name, "status": "not_found"} + # shutdown actor + try: + handle = org_info.get("handle") + if handle is not None: + await handle.shutdown.remote() + except Exception as e: + logger.warning(f"shutdown org_{name} failed: {e}") + # 移除 dispatch tool + self._dispatch_tools.pop(f"dispatch_to_{name}", None) + logger.info(f"uninstalled plugin: {name}") + return {"name": name, "status": "uninstalled"} + + async def reload(self, name: str) -> Dict[str, Any]: + """热重载(卸载 + 安装)。""" + await self.uninstall(name) + return await self.install(name) + + # ─── 查询接口 ────────────────────────────────────────────── + + def list_plugins(self) -> List[Dict[str, Any]]: + return [ + { + "name": name, + "display_name": info.get("display_name", name), + "description": info.get("description", ""), + "status": "running", + } + for name, info in self._orgs.items() + ] + + def get_dispatch_tools(self) -> Dict[str, Any]: + """返回所有 dispatch tools 的 {tool_name: callable} 字典。""" + return dict(self._dispatch_tools) + + # ─── 内部 ────────────────────────────────────────────────── + + async def _install_from_path(self, plugin_dir: Path) -> None: + cls, manifest_dict, agents_dict, dir_str = load_plugin(plugin_dir) + manifest = OrgManifest.model_validate(manifest_dict) + name = manifest.name + + # 装依赖 + if manifest.dependencies.python: + await install_dependencies(manifest.dependencies.python) + + # 实例化 organization actor + instance = cls(manifest_dict, agents_dict, dir_str) + await instance.setup() + + # 注册到 ray_actor_hook 命名空间 + actor_name = manifest.actor_name + if _STANDALONE: + register_standalone(actor_name, instance) + else: + # 分布式模式下,这里需要把 instance 包装成 ray actor + # 第一版走 standalone 逻辑(两种模式统一 register 到本进程) + # 真正分布式隔离等后续做 + register_standalone(actor_name, instance) + + # 生成 dispatch tool + tool = make_dispatch_tool(name, manifest.display_name, manifest.description) + self._dispatch_tools[f"dispatch_to_{name}"] = tool + + self._orgs[name] = { + "display_name": manifest.display_name, + "description": manifest.description, + "manifest": manifest_dict, + "handle": instance, + "actor_name": actor_name, + } + logger.info(f"loaded plugin: {name} (actor={actor_name})") diff --git a/kilostar/plugin_runtime/tool_bridge.py b/kilostar/plugin_runtime/tool_bridge.py new file mode 100644 index 0000000..3fd8f08 --- /dev/null +++ b/kilostar/plugin_runtime/tool_bridge.py @@ -0,0 +1,52 @@ +"""把组织包装成 cabinet 可调用的高阶 tool。 + +每个组织 → 一个 ``dispatch_to_(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() + } diff --git a/kilostar/utils/access.py b/kilostar/utils/access.py index 29dc0a3..33ca3a6 100644 --- a/kilostar/utils/access.py +++ b/kilostar/utils/access.py @@ -16,14 +16,15 @@ from __future__ import annotations import os from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Annotated, Optional import jwt -from fastapi import HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status from pydantic import BaseModel, ValidationError from pwdlib import PasswordHash if TYPE_CHECKING: + from kilostar.core.postgres_database.model import UserAuthority from kilostar.core.postgres_database.model.user import User @@ -174,3 +175,61 @@ class Accessor: if not (has_alpha and has_digit): raise ValueError("密码必须同时包含字母和数字") return password_hasher.hash(password) + + +# ─── Role Check ────────────────────────────────────────────────────────────── + + +def _user_not_found_detail(request: Request | None = None) -> str: + from kilostar.utils.i18n import t + + loc = request.headers.get("accept-language") if request else None + return t("user_not_found", accept_language=loc) + + +async def get_authority(user_id: str) -> "UserAuthority": + """通过 PostgresDatabase Actor 查出指定用户的 ``UserAuthority``;用户不存在时抛 401。""" + from kilostar.utils.error import UserNotExistError + from kilostar.utils.i18n import t + from kilostar.utils.ray_hook import ray_actor_hook + + postgres_database = ray_actor_hook("postgres_database").postgres_database + try: + user_authority = await postgres_database.get_user_authority.remote( + user_id=user_id + ) + return user_authority + except UserNotExistError: + raise HTTPException(status_code=401, detail=t("user_not_found")) + except Exception as e: + if "UserNotExistError" in str(e): + raise HTTPException( + status_code=401, detail=t("user_not_found") + ) + raise + + +class RoleChecker: + """FastAPI 依赖:在路由级别按 ``UserAuthority`` 做最低权限校验。 + + 例:``Depends(RoleChecker(allowed_roles=UserAuthority.ADMINISTRATOR))``。 + """ + + def __init__(self, **kwargs): + self.allowed_roles = kwargs.get( + "allowed_roles", + ) + + async def __call__( + self, token_data: Annotated[TokenData, Depends(Accessor.get_current_user)] + ): + """对当前请求执行权限比较,权限不足抛 403,否则把 ``TokenData`` 透传给路由。""" + user_authority = await get_authority(token_data.user_id) + if user_authority < self.allowed_roles: + raise HTTPException( + status_code=403, + detail={ + "message": f"User {token_data.user_id} does not have allowed roles" + }, + ) + return token_data diff --git a/kilostar/utils/banner.py b/kilostar/utils/banner.py index 646de16..d30af3b 100644 --- a/kilostar/utils/banner.py +++ b/kilostar/utils/banner.py @@ -14,14 +14,13 @@ from rich.console import Console from rich.text import Text -import yaml + +from kilostar.utils.config_loader import get_app_config def print_banner() -> None: """在启动阶段输出 KiloStar 的 ASCII 横幅与版本/作者元信息。""" - with open("config/config.yml", "r") as config: - config = yaml.load(config, Loader=yaml.FullLoader) - version = config.get("version", "unknown") + version = get_app_config().app.version kilostar_banner = r""" ██╗ ██╗██╗██╗ ██████╗ ███████╗████████╗ █████╗ ██████╗ ██║ ██╔╝██║██║ ██╔═══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗ diff --git a/kilostar/utils/check_user/role_check.py b/kilostar/utils/check_user/role_check.py deleted file mode 100644 index f962adc..0000000 --- a/kilostar/utils/check_user/role_check.py +++ /dev/null @@ -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 diff --git a/kilostar/utils/get_tool.py b/kilostar/utils/get_tool.py index 9b0df6e..f2fc912 100644 --- a/kilostar/utils/get_tool.py +++ b/kilostar/utils/get_tool.py @@ -1,57 +1,74 @@ -# Copyright 2026 zhaoxi826 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import importlib.util +import json import os import sys -from typing import Callable, Dict, List +from typing import Callable, Dict, List, Optional from kilostar.utils.logger import get_logger +from kilostar.utils.settings import get_toolset_dir logger = get_logger("get_tool") _tool_cache: Dict[str, Callable] = {} +_manifest_cache: Optional[Dict[str, Dict]] = None + + +def _load_manifests() -> Dict[str, Dict]: + """扫描所有 toolset 的 manifest.json,建立 tool_name → {toolset_dir, file} 的映射。""" + global _manifest_cache + if _manifest_cache is not None: + return _manifest_cache + + _manifest_cache = {} + toolset_root = get_toolset_dir() + if not toolset_root.exists(): + return _manifest_cache + + for item in toolset_root.iterdir(): + if not item.is_dir() or item.name.startswith("__"): + continue + manifest_path = item / "manifest.json" + if not manifest_path.exists(): + continue + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + for tool in manifest.get("tools", []): + tool_name = tool.get("name") + if tool_name: + _manifest_cache[tool_name] = { + "toolset_dir": str(item), + "toolset_name": item.name, + "file": tool.get("file", f"{tool_name}.py"), + } + except Exception as e: + logger.error(f"Failed to read manifest {manifest_path}: {e}") + + return _manifest_cache def _get_tool_func(tool_name: str) -> Callable | None: - """按名字从 ``kilostar/plugin/tool_plugin//__init__.py`` 中加载工具函数。 + """按名字从 toolset 中加载工具函数。 - 加载成功后会被缓存到模块级 ``_tool_cache``;找不到目录、找不到同名函数或 - 导入失败都会记录日志并返回 ``None``。 + 根据 manifest 找到工具所在的 toolset 和文件,动态加载模块并取出同名函数。 """ - func = _tool_cache.get(tool_name, None) + func = _tool_cache.get(tool_name) if func: return func - tool_plugin_dir = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "plugin", - "tool_plugin", - tool_name, - ) - - if not os.path.exists(tool_plugin_dir) or not os.path.isdir(tool_plugin_dir): - logger.error(f"Tool directory not found: {tool_plugin_dir}") + manifests = _load_manifests() + info = manifests.get(tool_name) + if not info: + logger.error(f"Tool '{tool_name}' not found in any toolset manifest") return None - init_file = os.path.join(tool_plugin_dir, "__init__.py") - if not os.path.exists(init_file): - logger.error(f"Tool init file not found: {init_file}") + tool_file = os.path.join(info["toolset_dir"], info["file"]) + if not os.path.exists(tool_file): + logger.error(f"Tool file not found: {tool_file}") return None try: - module_name = f"kilostar.plugin.tool_plugin.{tool_name}" - spec = importlib.util.spec_from_file_location(module_name, init_file) + module_name = f"data.toolset.{info['toolset_name']}.{tool_name}" + spec = importlib.util.spec_from_file_location(module_name, tool_file) if spec is None or spec.loader is None: logger.error(f"Failed to create spec for {module_name}") return None @@ -70,7 +87,7 @@ def _get_tool_func(tool_name: str) -> Callable | None: _tool_cache[tool_name] = func return func except Exception as e: - logger.error(f"Failed to load module {module_name}: {e}") + logger.error(f"Failed to load module {tool_name}: {e}") return None @@ -80,6 +97,12 @@ def del_tool_cache(tool_name: str) -> None: del _tool_cache[tool_name] +def invalidate_manifest_cache() -> None: + """清除 manifest 缓存,下次加载时重新扫描磁盘。""" + global _manifest_cache + _manifest_cache = None + + def load_tools_from_list(tool_names: List[str] | None) -> List[Callable]: """批量加载工具:传入工具名列表,返回成功加载到的函数对象列表(失败项被跳过)。""" if not tool_names: diff --git a/kilostar/utils/i18n.py b/kilostar/utils/i18n.py index ebebb59..e9c0553 100644 --- a/kilostar/utils/i18n.py +++ b/kilostar/utils/i18n.py @@ -17,7 +17,6 @@ 设计原则: - 纯内存字典,无文件 IO,Ray 远程序列化零成本。 - 支持环境变量 ``KILOSTAR_LANG`` 作为全局默认语言。 -- Agent system prompt 按 ``{locale}`` 分桶,调用方显式传入 locale。 - API 层通过请求头 ``Accept-Language`` 解析首选语言。 当前支持:``zh`` (简体中文), ``en`` (English)。 @@ -31,93 +30,6 @@ from kilostar.utils.settings import get_settings _DEFAULT_LOCALE: str = get_settings().kilostar_lang -# ─── Agent System Prompts ────────────────────────────────────────────────── - -_PROMPTS: Dict[str, Dict[str, str]] = { - "regulatory_node": { - "zh": ( - "你叫kilostar,是一个多智能体AI助手系统中的【监管节点 (Regulatory Node)】。\n" - "你是系统中直接面向用户的对话节点,负责理解用户需求并提供高质量的回复。\n\n" - "你的核心职责:\n" - "1. 准确理解用户的意图,提供专业、友好且有帮助的回复。\n" - "2. 如果你有可用工具,可以主动调用工具来辅助回答(如搜索、文件操作等)。\n" - "3. 如果你收到工作流的执行报告,请将其转化为面向用户的清晰总结。\n" - "4. 保持回复简洁、有结构,避免冗余信息。\n" - "请保持专业、友好的沟通风格。" - ), - "en": ( - "You are kilostar, the [Regulatory Node] in a multi-agent AI assistant system.\n" - "You are the user-facing conversational node, responsible for understanding user needs and providing high-quality responses.\n\n" - "Your core responsibilities:\n" - "1. Accurately understand user intent and provide professional, friendly, and helpful replies.\n" - "2. If tools are available, proactively use them to assist your responses (e.g., search, file operations).\n" - "3. If you receive a workflow execution report, convert it into a clear user-facing summary.\n" - "4. Keep responses concise, well-structured, and free of redundancy.\n" - "Maintain a professional and friendly communication style." - ), - }, - "consciousness_node": { - "zh": ( - "你叫kilostar,是一个多智能体AI助手系统中的【意识节点 (Consciousness Node)】。\n" - "你是系统的'高级规划师'和'架构师',负责处理监控节点分配过来的复杂任务。\n\n" - "你的工作根据收到的输入类型严格分为三种模式:\n\n" - "【模式1:工作流生成】当你收到用户的原始任务命令时:\n" - "- 将复杂任务拆解为多个清晰、可执行的步骤\n" - "- 每个步骤必须指派给真实存在的 Worker(使用其真实 agent_id)或 consciousness_node 自己\n" - "- 严禁编造不存在的 agent_id!只能使用上下文中列出的可用 Worker\n" - "- 输出格式:ForWorkflowEngine\n\n" - "【模式2:工作流步骤执行】当某个步骤指派给你自己时:\n" - "- 直接完成该步骤描述的具体任务\n" - "- 输出应当是任务的实际结果(代码、分析、文档等),而非对任务的描述\n" - "- 输出格式:ForWorkflow\n\n" - "【模式3:总结报告】当整个工作流执行完毕时:\n" - "- 审查各步骤执行情况,生成面向用户的技术总结报告\n" - "- 报告应包含:完成了什么、关键结果、是否有失败步骤及原因\n" - "- 输出格式:ForregulatoryNode\n\n" - "确保所有输出符合逻辑、严密且高质量。" - ), - "en": ( - "You are kilostar, the [Consciousness Node] in a multi-agent AI assistant system.\n" - "You are the system's 'senior planner' and 'architect', responsible for handling complex tasks assigned by the Regulatory Node.\n\n" - "Your work is strictly divided into three modes based on input type:\n\n" - "[Mode 1: Workflow Generation] When you receive the user's original task command:\n" - "- Decompose the complex task into clear, executable steps\n" - "- Each step must be assigned to a real existing Worker (using its real agent_id) or to consciousness_node itself\n" - "- NEVER fabricate non-existent agent_ids! Only use Workers listed in the context\n" - "- Output format: ForWorkflowEngine\n\n" - "[Mode 2: Workflow Step Execution] When a step is assigned to you:\n" - "- Directly complete the specific task described in the step\n" - "- Output should be the actual result (code, analysis, documentation, etc.), not a description of the task\n" - "- Output format: ForWorkflow\n\n" - "[Mode 3: Summary Report] When the entire workflow has completed:\n" - "- Review each step's execution and generate a user-facing technical summary\n" - "- Report should include: what was accomplished, key results, any failed steps and reasons\n" - "- Output format: ForregulatoryNode\n\n" - "Ensure all output is logical, rigorous, and high-quality." - ), - }, - "control_node": { - "zh": ( - "你叫kilostar,是一个多智能体AI助手系统中的【控制节点 (Control Node)】。\n" - "你是系统的'执行者'和'车间主任',专门负责执行工作流中分配给你的具体子任务。\n" - "你的工作职责是:\n" - "1. 仔细分析分配给你的工作流步骤 (workflow_step) 的目标和要求。\n" - "2. 运用你被分配的工具 (如有) 或者依靠自身的知识和推理能力,精准、高效地完成该任务。\n" - "3. 将执行的结果、产生的数据或者具体的输出,严格按照 ForWorkflow 格式返回。\n" - "请注意:你的输出应当具体、实用,直接提供任务所要求的结果,不要做过多无关的寒暄。" - ), - "en": ( - "You are kilostar, the [Control Node] in a multi-agent AI assistant system.\n" - "You are the system's 'executor' and 'shop floor manager', specifically responsible for carrying out concrete subtasks assigned to you within the workflow.\n" - "Your duties are:\n" - "1. Carefully analyze the objectives and requirements of the workflow_step assigned to you.\n" - "2. Use the tools assigned to you (if any) or rely on your own knowledge and reasoning to complete the task accurately and efficiently.\n" - "3. Return the execution results, generated data, or concrete outputs strictly in the ForWorkflow format.\n" - "Note: Your output should be specific, practical, and directly provide the results requested by the task. Avoid excessive irrelevant pleasantries." - ), - }, -} - # ─── API / 通用消息 ──────────────────────────────────────────────────────── _MESSAGES: Dict[str, Dict[str, str]] = { @@ -158,7 +70,6 @@ def _resolve_locale(locale: str | None = None, accept_language: str | None = Non if locale: return locale if locale in ("zh", "en") else _DEFAULT_LOCALE if accept_language: - # 简单解析:取第一个 segment,若含 zh 则 zh,含 en 则 en first = accept_language.split(",")[0].split(";")[0].strip().lower() if "zh" in first: return "zh" @@ -182,26 +93,3 @@ def t(key: str, locale: str | None = None, accept_language: str | None = None, * loc = _resolve_locale(locale, accept_language) text = _MESSAGES.get(loc, {}).get(key) or _MESSAGES.get(_DEFAULT_LOCALE, {}).get(key) or key return text.format(**kwargs) if kwargs else text - - -def agent_prompt( - agent_name: str, - locale: str | None = None, - accept_language: str | None = None, - custom_system_prompt: str | None = None, -) -> str: - """获取指定 Agent 的 system prompt,并追加语言指令。 - - 若 ``custom_system_prompt`` 不为空,追加在默认 prompt 和语言指令之后, - 使管理员自定义内容能够覆盖/补充默认行为,同时保留角色定义。 - """ - loc = _resolve_locale(locale, accept_language) - prompt = _PROMPTS.get(agent_name, {}).get(loc) or _PROMPTS.get(agent_name, {}).get(_DEFAULT_LOCALE, "") - lang_instruction = { - "zh": "\n\n【重要】请始终使用简体中文进行思考和回复。", - "en": "\n\n[Important] Please always think and reply in English.", - }.get(loc, "") - result = prompt + lang_instruction - if custom_system_prompt and custom_system_prompt.strip(): - result += f"\n\n{custom_system_prompt.strip()}" - return result diff --git a/kilostar/utils/pickle.py b/kilostar/utils/pickle.py deleted file mode 100644 index e229911..0000000 --- a/kilostar/utils/pickle.py +++ /dev/null @@ -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 diff --git a/kilostar/utils/prompts.py b/kilostar/utils/prompts.py new file mode 100644 index 0000000..5b3e838 --- /dev/null +++ b/kilostar/utils/prompts.py @@ -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 diff --git a/kilostar/utils/standalone_proxy.py b/kilostar/utils/ray_compat.py similarity index 80% rename from kilostar/utils/standalone_proxy.py rename to kilostar/utils/ray_compat.py index 3034587..f69bc14 100644 --- a/kilostar/utils/standalone_proxy.py +++ b/kilostar/utils/ray_compat.py @@ -1,4 +1,4 @@ -"""KiloStar 单机模式适配层:用 asyncio 协程模拟 Ray Actor 接口。 +"""KiloStar Ray 兼容层:单机/分布式模式无感切换 + 序列化工具。 单机模式下,所有 Actor 退化为普通 Python 异步单例,通过 StandaloneProxy 包装后暴露与 Ray Actor Handle 相同的 `.method.remote(args)` 调用接口, @@ -9,10 +9,14 @@ from __future__ import annotations import asyncio import os -from typing import Any +from typing import Any, Type, TypeVar + +from pydantic import BaseModel _STANDALONE = os.environ.get("KILOSTAR_MODE", "distributed") == "standalone" +T = TypeVar("T", bound=Type[BaseModel]) + class _MethodProxy: """包装单个方法,使 .remote(*args, **kwargs) 返回一个可 await 的 Task。""" @@ -84,3 +88,19 @@ def remote_task(func): import ray return ray.remote(func) + + +# ─── Pickle (Ray 序列化优化) ─── + + +def pickle(cls: T) -> T: + """类装饰器:用 Pydantic 的高效 JSON 序列化替代 Python 原生 __reduce__, + 使 Ray 跨进程通信时对 BaseModel 子类走 Rust 级序列化。 + """ + + def __reduce__(self): + data = self.model_dump_json() + return cls.model_validate_json, (data,) + + cls.__reduce__ = __reduce__ + return cls diff --git a/kilostar/utils/ray_hook.py b/kilostar/utils/ray_hook.py index bdabea3..cc2ebed 100644 --- a/kilostar/utils/ray_hook.py +++ b/kilostar/utils/ray_hook.py @@ -15,7 +15,7 @@ import time from functools import lru_cache from typing import Any, Dict -from kilostar.utils.standalone_proxy import _STANDALONE +from kilostar.utils.ray_compat import _STANDALONE if not _STANDALONE: import ray @@ -49,7 +49,7 @@ _standalone_registry: Dict[str, Any] = {} def register_standalone(name: str, instance: Any) -> None: """注册一个单机模式下的 Actor 单例(已包装为 StandaloneProxy)。""" - from kilostar.utils.standalone_proxy import StandaloneProxy + from kilostar.utils.ray_compat import StandaloneProxy _standalone_registry[name] = StandaloneProxy(instance) diff --git a/kilostar/utils/settings.py b/kilostar/utils/settings.py index 4ce1f70..f77d8d5 100644 --- a/kilostar/utils/settings.py +++ b/kilostar/utils/settings.py @@ -41,6 +41,9 @@ class AppSettings(BaseSettings): kilostar_mode: str = "distributed" kilostar_lang: str = "zh" kilostar_cors_origins: str = "" + kilostar_plugin_dir: str = "" + kilostar_toolset_dir: str = "" + kilostar_artifact_dir: str = "" db: DatabaseSettings = Field(default_factory=DatabaseSettings) security: SecuritySettings = Field(default_factory=SecuritySettings) @@ -53,3 +56,46 @@ class AppSettings(BaseSettings): @lru_cache(maxsize=1) def get_settings() -> AppSettings: return AppSettings() + + +def get_plugin_dir() -> "pathlib.Path": + """返回插件根目录路径(包含 tool_plugin/ 和 skill/ 子目录)。 + + 优先使用环境变量 KILOSTAR_PLUGIN_DIR,否则默认 /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,否则默认 /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,否则默认 /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" diff --git a/kilostar/worker_cluster/worker_cluster.py b/kilostar/worker_cluster/worker_cluster.py index 6b114a7..188b771 100644 --- a/kilostar/worker_cluster/worker_cluster.py +++ b/kilostar/worker_cluster/worker_cluster.py @@ -15,7 +15,7 @@ import time import asyncio from collections import OrderedDict -from kilostar.utils.standalone_proxy import actor_class, _STANDALONE +from kilostar.utils.ray_compat import actor_class, _STANDALONE from kilostar.utils.ray_hook import ray_actor_hook if _STANDALONE: diff --git a/kilostar/worker_individual/skill_individual.py b/kilostar/worker_individual/skill_individual.py index c446d4b..9450f98 100644 --- a/kilostar/worker_individual/skill_individual.py +++ b/kilostar/worker_individual/skill_individual.py @@ -35,9 +35,10 @@ class SkillIndividual(BaseIndividual): async def _load_skill_tools(self): """动态加载已绑定的 skill 工具。""" + from kilostar.utils.settings import get_plugin_dir + tools = [] bound_skill = self.agent_config.get("bound_skill", "") - # bound_skill can be string or dict {"skill_name": ["file1", "file2"]} skill_mapper = {} if isinstance(bound_skill, str) and bound_skill: try: @@ -47,9 +48,7 @@ class SkillIndividual(BaseIndividual): elif isinstance(bound_skill, dict): skill_mapper = bound_skill - skill_base_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "plugin", "skill") - ) + skill_base_dir = str(get_plugin_dir() / "skill") for skill_name, _ in skill_mapper.items(): skill_path = os.path.join(skill_base_dir, skill_name) diff --git a/main.py b/main.py index a2e61d3..272228e 100644 --- a/main.py +++ b/main.py @@ -41,6 +41,7 @@ from kilostar.core.global_state_machine import GlobalStateMachine from kilostar.core.global_workflow_manager import GlobalWorkflowManager from kilostar.core.individual.regulatory_node import RegulatoryNode from kilostar.core.individual.consciousness_node import ConsciousnessNode +from kilostar.plugin_runtime.plugin_manager import GlobalPluginManager if KILOSTAR_MODE != "standalone": import ray @@ -58,7 +59,7 @@ async def start_standalone(): await postgres_database.init_db() register_standalone("postgres_database", postgres_database) - from kilostar.utils.standalone_proxy import StandaloneProxy + from kilostar.utils.ray_compat import StandaloneProxy postgres_proxy = StandaloneProxy(postgres_database) global_state_machine = GlobalStateMachine(postgres_proxy) @@ -83,6 +84,10 @@ async def start_standalone(): register_standalone("worker_cluster_core", worker_cluster) register_standalone("worker_cluster_gpu", worker_cluster) + plugin_manager = GlobalPluginManager() + await plugin_manager.bootstrap() + register_standalone("global_plugin_manager", plugin_manager) + print(f"✅ KiloStar 单机模式启动完成,监听 0.0.0.0:8000") config = uvicorn.Config(app, host="0.0.0.0", port=8000, log_level="info") @@ -162,6 +167,15 @@ async def start_distributed(): print(f"\n[致命错误] GlobalWorkflowManager 启动失败!\n{e}\n") return + plugin_manager = GlobalPluginManager.options( + name="global_plugin_manager", namespace="kilostar", lifetime="detached" + ).remote() + try: + await plugin_manager.bootstrap.remote() + print("✅ GlobalPluginManager 初始化成功!") + except Exception as e: + print(f"⚠️ GlobalPluginManager 启动失败(非致命): {e}") + serve.start(http_options={"host": "0.0.0.0", "port": 8000}) serve.run(KiloStarGateway.bind()) diff --git a/tests/unit/test_api_agent_template.py b/tests/unit/test_api_agent_template.py index f59b108..50c2289 100644 --- a/tests/unit/test_api_agent_template.py +++ b/tests/unit/test_api_agent_template.py @@ -28,8 +28,8 @@ def _tpl(owner: str = "alice", is_builtin: bool = False): @pytest.fixture def app(monkeypatch): - import kilostar.utils.check_user.role_check as rc - monkeypatch.setattr(rc, "get_authority", AsyncMock(return_value=UserAuthority.USER)) + import kilostar.utils.access as access_mod + monkeypatch.setattr(access_mod, "get_authority", AsyncMock(return_value=UserAuthority.USER)) _app = FastAPI() _app.include_router(agent_router) _app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user() diff --git a/tests/unit/test_api_custom_toolset_auth.py b/tests/unit/test_api_custom_toolset_auth.py index c78b6af..b106739 100644 --- a/tests/unit/test_api_custom_toolset_auth.py +++ b/tests/unit/test_api_custom_toolset_auth.py @@ -21,17 +21,16 @@ def _fake_user(user_id: str = "alice"): @pytest.fixture def app_with_user(monkeypatch): """挂上 resource_router;用 dependency_overrides 跳过 JWT,并把 get_authority 默认放成 USER。""" + import kilostar.utils.access as access_mod + app = FastAPI() app.include_router(resource_router) app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user("alice") - # 默认把权限置为 USER;具体 case 内部可再 monkeypatch 覆盖 async def _default_authority(uid): return UserAuthority.USER - monkeypatch.setattr( - "kilostar.utils.check_user.role_check.get_authority", _default_authority - ) + monkeypatch.setattr(access_mod, "get_authority", _default_authority) return app @@ -87,9 +86,8 @@ async def test_get_custom_toolset_allowed_for_admin( async def _admin(uid): return UserAuthority.SUPER_ADMINISTRATOR - monkeypatch.setattr( - "kilostar.utils.check_user.role_check.get_authority", _admin - ) + import kilostar.utils.access as access_mod + monkeypatch.setattr(access_mod, "get_authority", _admin) transport = ASGITransport(app=app_with_user) async with AsyncClient(transport=transport, base_url="http://test") as client: diff --git a/tests/unit/test_plugin_metadata.py b/tests/unit/test_plugin_metadata.py index 2194464..8208596 100644 --- a/tests/unit/test_plugin_metadata.py +++ b/tests/unit/test_plugin_metadata.py @@ -1,41 +1,89 @@ -"""``plugin/tool_plugin`` 下各工具的元数据类正确性。 +"""``data/toolset/`` manifest.json 加载正确性测试。 -``BaseToolData`` 本身不带 ``name`` 字段;工具名以目录名为准(由 ``GlobalToolManager`` -扫描时注入到 ``tool_metadata`` 中)。这里只验证子类对 BaseToolData 字段的覆写。 +验证 GlobalToolManager 从 manifest.json 正确读取工具元数据。 """ -from kilostar.plugin.tool_plugin.approval.approval import ApprovalToolData -from kilostar.plugin.tool_plugin.file_reader import FileReaderToolData -from kilostar.plugin.tool_plugin.tavily_search import TavilySearchToolData +import json +from pathlib import Path + +_toolset_dir = Path(__file__).parent.parent.parent / "data" / "toolset" +_base_toolset_dir = _toolset_dir / "base_toolset" +_interactive_toolset_dir = _toolset_dir / "interactive_toolset" + + +def _read_manifest(toolset_dir=_base_toolset_dir): + with open(toolset_dir / "manifest.json", "r", encoding="utf-8") as f: + return json.load(f) + + +def _get_tool_def(manifest, name): + for tool in manifest["tools"]: + if tool["name"] == name: + return tool + return None + + +def test_manifest_json_exists(): + assert (_base_toolset_dir / "manifest.json").exists() + assert (_interactive_toolset_dir / "manifest.json").exists() + + +def test_manifest_has_all_tools(): + base_names = {t["name"] for t in _read_manifest(_base_toolset_dir)["tools"]} + interactive_names = { + t["name"] for t in _read_manifest(_interactive_toolset_dir)["tools"] + } + assert base_names == { + "shell_executor", "file_reader", "edit_file", "write_file", + "search_file", "python_executor", "tavily_search", + } + assert interactive_names == {"approval", "send_file"} def test_approval_metadata(): - data = ApprovalToolData() - assert data.is_system is True - assert data.category == "system" - # action_scope 为空表示分配给 default 组(所有节点可用) - assert data.action_scope == [] - - -def test_file_reader_metadata(): - data = FileReaderToolData() - assert data.is_system is True - assert data.category == "system" - assert data.action_scope == [] + manifest = _read_manifest(_interactive_toolset_dir) + tool = _get_tool_def(manifest, "approval") + assert tool["is_system"] is True + assert tool["category"] == "system" + assert tool["action_scope"] == [] def test_tavily_search_metadata(): - data = TavilySearchToolData() - assert data.is_system is False - assert data.category == "search" - assert "control_node" in data.action_scope - assert "consciousness_node" in data.action_scope - # 默认配置 schema 含 api_key 字段(用于 GSM 配置面板) - assert "api_key" in data.config_args + manifest = _read_manifest() + tool = _get_tool_def(manifest, "tavily_search") + assert tool["is_system"] is False + assert tool["category"] == "search" + assert "control_node" in tool["action_scope"] + assert "consciousness_node" in tool["action_scope"] + assert "api_key" in tool["config_args"] -def test_base_tool_extra_allowed(): - """``ConfigDict(extra="allow")`` 允许子类外的 KV 也能装进来。""" - data = ApprovalToolData(some_extension="ok") # type: ignore[call-arg] - assert data.model_extra is not None - assert data.model_extra.get("some_extension") == "ok" +def test_all_tool_files_exist(): + for toolset_dir in (_base_toolset_dir, _interactive_toolset_dir): + manifest = _read_manifest(toolset_dir) + for tool in manifest["tools"]: + file_path = toolset_dir / tool["file"] + assert file_path.exists(), f"Missing tool file: {tool['file']}" + + +def test_tool_manager_loads_all_tools(): + from kilostar.core.global_state_machine.tool_manager import GlobalToolManager + + tm = GlobalToolManager() + assert len(tm.tool_metadata) == 9 + assert "shell_executor" in tm.tool_metadata + assert "tavily_search" in tm.tool_metadata + assert "approval" in tm.tool_metadata + + +def test_tool_manager_system_vs_third_party(): + from kilostar.core.global_state_machine.tool_manager import GlobalToolManager + + tm = GlobalToolManager() + system_tools = tm.get_system_tools() + third_party = tm.get_third_party_tools() + system_names = {t["name"] for t in system_tools} + tp_names = {t["name"] for t in third_party} + assert "shell_executor" in system_names + assert "tavily_search" in tp_names + assert "tavily_search" not in system_names diff --git a/tests/unit/test_plugin_runtime.py b/tests/unit/test_plugin_runtime.py new file mode 100644 index 0000000..48ef7de --- /dev/null +++ b/tests/unit/test_plugin_runtime.py @@ -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__ diff --git a/tests/unit/test_standalone_proxy.py b/tests/unit/test_ray_compat.py similarity index 81% rename from tests/unit/test_standalone_proxy.py rename to tests/unit/test_ray_compat.py index 7cc92da..aa1cf67 100644 --- a/tests/unit/test_standalone_proxy.py +++ b/tests/unit/test_ray_compat.py @@ -1,4 +1,4 @@ -"""standalone_proxy 适配层单元测试。 +"""ray_compat 适配层单元测试。 验证 StandaloneProxy / _MethodProxy / actor_class / remote_task 在单机模式下的行为是否正确模拟了 Ray Actor Handle 的 .remote() 接口。 @@ -7,8 +7,8 @@ import asyncio import pytest -from kilostar.utils import standalone_proxy -from kilostar.utils.standalone_proxy import StandaloneProxy, _MethodProxy +from kilostar.utils import ray_compat +from kilostar.utils.ray_compat import StandaloneProxy, _MethodProxy class TestMethodProxy: @@ -61,9 +61,9 @@ class TestStandaloneProxy: class TestActorClass: def test_standalone_returns_class_unchanged(self, monkeypatch): - monkeypatch.setattr(standalone_proxy, "_STANDALONE", True) + monkeypatch.setattr(ray_compat, "_STANDALONE", True) - @standalone_proxy.actor_class + @ray_compat.actor_class class MyActor: def do_work(self): return 42 @@ -72,9 +72,9 @@ class TestActorClass: assert instance.do_work() == 42 def test_standalone_class_is_plain_python(self, monkeypatch): - monkeypatch.setattr(standalone_proxy, "_STANDALONE", True) + monkeypatch.setattr(ray_compat, "_STANDALONE", True) - @standalone_proxy.actor_class + @ray_compat.actor_class class MyActor: pass @@ -84,9 +84,9 @@ class TestActorClass: class TestRemoteTask: def test_sync_task(self, monkeypatch): - monkeypatch.setattr(standalone_proxy, "_STANDALONE", True) + monkeypatch.setattr(ray_compat, "_STANDALONE", True) - @standalone_proxy.remote_task + @ray_compat.remote_task def multiply(a, b): return a * b @@ -95,9 +95,9 @@ class TestRemoteTask: assert result == 12 def test_async_task(self, monkeypatch): - monkeypatch.setattr(standalone_proxy, "_STANDALONE", True) + monkeypatch.setattr(ray_compat, "_STANDALONE", True) - @standalone_proxy.remote_task + @ray_compat.remote_task async def async_multiply(a, b): return a * b