diff --git a/.gitignore b/.gitignore index 0e7ac78..60f2a28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,11 @@ -# Python-generated files -__pycache__/ -*.py[oc] -build/ -dist/ -wheels/ -*.egg-info - -# Virtual environments -.venv -.idea -# Local runtime data (MCP registry, etc.) -data/ +# 项目运行时数据:默认全部忽略,仅显式开放需要纳入版本控制的子目录 +data/* +!data/toolset/ !data/plugin/ data/plugin/skill/ -!data/toolset/ + tmp/ .env + +.idea/ +.venv/ \ No newline at end of file diff --git a/alembic/versions/2026_06_17_0000-0010_provider_model_settings.py b/alembic/versions/2026_06_17_0000-0010_provider_model_settings.py new file mode 100644 index 0000000..defb17f --- /dev/null +++ b/alembic/versions/2026_06_17_0000-0010_provider_model_settings.py @@ -0,0 +1,26 @@ +"""add model_settings JSONB column to provider table + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-06-17 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0010" +down_revision = "0009" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "provider", + sa.Column("model_settings", JSONB, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("provider", "model_settings") diff --git a/data/plugin/example_dept/README.md b/data/plugin/example_dept/README.md new file mode 100644 index 0000000..fd91fa1 --- /dev/null +++ b/data/plugin/example_dept/README.md @@ -0,0 +1,16 @@ +# 示例部门 (Example Dept) + +演示用的重型插件骨架。包含两个平级 agent(analyst + executor), +可作为开发新组织插件的模板。 + +## 目录结构 + +``` +example_dept/ +├── manifest.json # 插件元数据 +├── agents.json # agent 定义 +├── core/ # 业务逻辑 +├── toolset/ # 本地工具 +├── skills/ # 本地技能 +└── dashboard/ # 前端面板(占位) +``` diff --git a/data/plugin/example_dept/agents.json b/data/plugin/example_dept/agents.json new file mode 100644 index 0000000..4e391e7 --- /dev/null +++ b/data/plugin/example_dept/agents.json @@ -0,0 +1,32 @@ +{ + "agents": [ + { + "name": "analyst", + "role": "数据分析专家", + "system_prompt": "你是一位数据分析专家,负责理解用户需求并给出分析方案。", + "model": { + "provider_title": "", + "model_id": "" + }, + "tools": [], + "skills": [], + "peers": ["executor"] + }, + { + "name": "executor", + "role": "执行专家", + "system_prompt": "你是一位执行专家,负责将分析方案转化为具体操作。", + "model": { + "provider_title": "", + "model_id": "" + }, + "tools": ["shell_executor", "python_executor"], + "skills": [], + "peers": ["analyst"] + } + ], + "orchestration": { + "type": "react", + "entry": "analyst" + } +} diff --git a/data/plugin/example_dept/core/__init__.py b/data/plugin/example_dept/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/plugin/example_dept/core/organization.py b/data/plugin/example_dept/core/organization.py new file mode 100644 index 0000000..292cf1f --- /dev/null +++ b/data/plugin/example_dept/core/organization.py @@ -0,0 +1,6 @@ +from kilostar.plugin_runtime.base_organization import BaseOrganization + + +class ExampleOrganization(BaseOrganization): + """示例组织 — 直接使用基类的 react 逻辑。""" + pass diff --git a/data/plugin/example_dept/manifest.json b/data/plugin/example_dept/manifest.json new file mode 100644 index 0000000..fcd463b --- /dev/null +++ b/data/plugin/example_dept/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "example_dept", + "version": "0.1.0", + "display_name": "示例部门", + "description": "演示用的重型插件骨架,可作为开发模板。", + "entry": "core.organization:ExampleOrganization", + "concurrency": "queue", + "node_affinity": "cpu", + "api_prefix": "/plugin/example_dept", + "capabilities": ["text_processing"], + "dependencies": { + "python": [], + "plugins": [] + }, + "ui": { + "entry": "dashboard/index.html", + "icon": null + } +} diff --git a/data/toolset/base_toolset/README.md b/data/toolset/base_toolset/README.md new file mode 100644 index 0000000..22f20cd --- /dev/null +++ b/data/toolset/base_toolset/README.md @@ -0,0 +1,30 @@ +# base_toolset + +KiloStar 内置基础工具集。提供文件操作、命令执行、搜索等通用能力,所有 Agent 默认可用。 + +## 工具列表 + +| 工具 | 说明 | +|------|------| +| `shell_executor` | 执行 shell 命令,返回 stdout/stderr | +| `file_reader` | 读取文件内容(支持按行偏移和行数限制) | +| `edit_file` | 按 old_string → new_string 的方式精确替换文件内容 | +| `write_file` | 整体写入或覆盖文件 | +| `search_file` | 在目录树内按 glob/正则搜索文件名或内容 | +| `python_executor` | 在沙箱中运行 Python 代码片段 | +| `tavily_search` | 调用 Tavily API 进行联网搜索(需配置 `api_key`) | + +## 配置说明 + +`tavily_search` 需要在工具配置中填入 `api_key`,可选参数: + +- `max_results`:返回结果条数,默认 `5` +- `search_depth`:`basic` 或 `advanced` +- `include_answer`:是否带 LLM 摘要,默认 `true` + +其他工具开箱即用,无需配置。 + +## 安全提示 + +- `shell_executor` / `python_executor` 会在受限沙箱内执行,但仍建议在受信环境下使用 +- `edit_file` / `write_file` 会修改本地文件系统,注意权限范围 diff --git a/data/toolset/base_toolset/__init__.py b/data/toolset/base_toolset/__init__.py new file mode 100644 index 0000000..2d1465e --- /dev/null +++ b/data/toolset/base_toolset/__init__.py @@ -0,0 +1,17 @@ +from .shell_executor import shell_executor +from .file_reader import file_reader +from .edit_file import edit_file +from .write_file import write_file +from .search_file import search_file +from .python_executor import python_executor +from .tavily_search import tavily_search + +__all__ = [ + "shell_executor", + "file_reader", + "edit_file", + "write_file", + "search_file", + "python_executor", + "tavily_search", +] diff --git a/data/toolset/base_toolset/edit_file.py b/data/toolset/base_toolset/edit_file.py new file mode 100644 index 0000000..82e5328 --- /dev/null +++ b/data/toolset/base_toolset/edit_file.py @@ -0,0 +1,43 @@ +import os + + +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/data/toolset/base_toolset/file_reader.py b/data/toolset/base_toolset/file_reader.py new file mode 100644 index 0000000..250145d --- /dev/null +++ b/data/toolset/base_toolset/file_reader.py @@ -0,0 +1,23 @@ +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/data/toolset/base_toolset/manifest.json b/data/toolset/base_toolset/manifest.json new file mode 100644 index 0000000..2770186 --- /dev/null +++ b/data/toolset/base_toolset/manifest.json @@ -0,0 +1,68 @@ +{ + "name": "基础工具集", + "version": "0.1.0", + "description": "文件读写、命令执行、Python/搜索等通用能力", + "tools": [ + { + "name": "shell_executor", + "file": "shell_executor.py", + "is_system": true, + "action_scope": [], + "config_args": {}, + "category": "system" + }, + { + "name": "file_reader", + "file": "file_reader.py", + "is_system": true, + "action_scope": [], + "config_args": {}, + "category": "system" + }, + { + "name": "edit_file", + "file": "edit_file.py", + "is_system": true, + "action_scope": [], + "config_args": {}, + "category": "system" + }, + { + "name": "write_file", + "file": "write_file.py", + "is_system": true, + "action_scope": [], + "config_args": {}, + "category": "system" + }, + { + "name": "search_file", + "file": "search_file.py", + "is_system": true, + "action_scope": [], + "config_args": {}, + "category": "system" + }, + { + "name": "python_executor", + "file": "python_executor.py", + "is_system": true, + "action_scope": [], + "config_args": {}, + "category": "system" + }, + { + "name": "tavily_search", + "file": "tavily_search.py", + "is_system": false, + "action_scope": ["control_node", "consciousness_node", "regulatory_node"], + "config_args": { + "api_key": "", + "max_results": "5", + "search_depth": "basic", + "include_answer": "true" + }, + "category": "search" + } + ] +} diff --git a/data/toolset/base_toolset/python_executor.py b/data/toolset/base_toolset/python_executor.py new file mode 100644 index 0000000..0f075df --- /dev/null +++ b/data/toolset/base_toolset/python_executor.py @@ -0,0 +1,59 @@ +import asyncio +import sys +import tempfile +import os + + +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/data/toolset/base_toolset/search_file.py b/data/toolset/base_toolset/search_file.py new file mode 100644 index 0000000..00ec9f5 --- /dev/null +++ b/data/toolset/base_toolset/search_file.py @@ -0,0 +1,55 @@ +import asyncio + + +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/data/toolset/base_toolset/shell_executor.py b/data/toolset/base_toolset/shell_executor.py new file mode 100644 index 0000000..804af40 --- /dev/null +++ b/data/toolset/base_toolset/shell_executor.py @@ -0,0 +1,46 @@ +import asyncio + + +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/data/toolset/base_toolset/tavily_search.py b/data/toolset/base_toolset/tavily_search.py new file mode 100644 index 0000000..542bd89 --- /dev/null +++ b/data/toolset/base_toolset/tavily_search.py @@ -0,0 +1,78 @@ +import os +from typing import Optional + +from tavily import AsyncTavilyClient + + +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 + + 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/data/toolset/base_toolset/write_file.py b/data/toolset/base_toolset/write_file.py new file mode 100644 index 0000000..1e44396 --- /dev/null +++ b/data/toolset/base_toolset/write_file.py @@ -0,0 +1,31 @@ +import os + + +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/data/toolset/interactive_toolset/README.md b/data/toolset/interactive_toolset/README.md new file mode 100644 index 0000000..de9e11c --- /dev/null +++ b/data/toolset/interactive_toolset/README.md @@ -0,0 +1,26 @@ +# interactive__toolset + +KiloStar 工作流交互工具集。这些工具用于 Agent 与用户/前端之间的实时交互,依赖 `global_workflow_manager` 的消息通道。 + +## 工具列表 + +| 工具 | 说明 | +|------|------| +| `approval` | 在执行高风险操作前向用户发送审批请求,阻塞等待用户回复 | +| `send_file` | 把 Agent 生成的文件作为附件推送到当前对话窗口,前端渲染为可下载卡片 | + +## 使用前提 + +这两个工具需要工作流上下文:调用方必须在 deps 中传入 `trace_id`,工具会通过 `global_workflow_manager` 的 pending 队列与前端通信。 + +- 在普通聊天场景下,`send_file` 在 `trace_id` 为空时会退化为直接返回文件内容字符串 +- `approval` 在没有合法 `trace_id` 时会一直阻塞,建议仅在工作流节点中绑定 + +## 配置说明 + +无需任何配置,开箱即用。 + +## 适用场景 + +- Agent 计划执行删除、转账等高风险操作前的人工确认 +- 让 Agent 把生成的报告、代码片段、图表数据以文件形式投递给用户 diff --git a/data/toolset/interactive_toolset/__init__.py b/data/toolset/interactive_toolset/__init__.py new file mode 100644 index 0000000..5ddf169 --- /dev/null +++ b/data/toolset/interactive_toolset/__init__.py @@ -0,0 +1,5 @@ +from .approval import approval + +__all__ = [ + "approval", +] diff --git a/data/toolset/interactive_toolset/approval.py b/data/toolset/interactive_toolset/approval.py new file mode 100644 index 0000000..543aa05 --- /dev/null +++ b/data/toolset/interactive_toolset/approval.py @@ -0,0 +1,17 @@ +from kilostar.utils.ray_hook import ray_actor_hook + + +async def approval(message: str, trace_id: str) -> str: + """当任务存在某些高风险操作或者计划需要让用户审批,发送请求给用户等待用户审批。 + + Args: + message: 发送给用户的请求 + trace_id: 当前工作流的 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/data/toolset/interactive_toolset/manifest.json b/data/toolset/interactive_toolset/manifest.json new file mode 100644 index 0000000..40041c1 --- /dev/null +++ b/data/toolset/interactive_toolset/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "交互工具集", + "version": "0.1.0", + "description": "工作流场景下与用户/前端交互的工具(HITL 审批)", + "tools": [ + { + "name": "approval", + "file": "approval.py", + "is_system": true, + "action_scope": [], + "config_args": {}, + "category": "system" + } + ] +} diff --git a/data/toolset/regulatory_toolset/__init__.py b/data/toolset/regulatory_toolset/__init__.py new file mode 100644 index 0000000..a942344 --- /dev/null +++ b/data/toolset/regulatory_toolset/__init__.py @@ -0,0 +1,9 @@ +from .query_workflow_status import query_workflow_status +from .query_task_list import query_task_list +from .send_file import send_file + +__all__ = [ + "query_workflow_status", + "query_task_list", + "send_file", +] diff --git a/data/toolset/regulatory_toolset/manifest.json b/data/toolset/regulatory_toolset/manifest.json new file mode 100644 index 0000000..3b190fc --- /dev/null +++ b/data/toolset/regulatory_toolset/manifest.json @@ -0,0 +1,31 @@ +{ + "name": "监管节点工具集", + "version": "0.1.0", + "description": "监管节点(regulatory_node)专属能力:查询工作流、查询任务列表、发送文件", + "tools": [ + { + "name": "query_workflow_status", + "file": "query_workflow_status.py", + "is_system": true, + "action_scope": ["regulatory_node"], + "config_args": {}, + "category": "system" + }, + { + "name": "query_task_list", + "file": "query_task_list.py", + "is_system": true, + "action_scope": ["regulatory_node"], + "config_args": {}, + "category": "system" + }, + { + "name": "send_file", + "file": "send_file.py", + "is_system": true, + "action_scope": ["regulatory_node"], + "config_args": {}, + "category": "system" + } + ] +} diff --git a/data/toolset/regulatory_toolset/query_task_list.py b/data/toolset/regulatory_toolset/query_task_list.py new file mode 100644 index 0000000..be32169 --- /dev/null +++ b/data/toolset/regulatory_toolset/query_task_list.py @@ -0,0 +1,57 @@ +"""query_task_list:列出当前用户的所有工作流任务。 + +regulatory_node 用以回答"我有哪些任务/正在跑什么"。返回精简后的任务列表, +不包含 graph state、context 等大字段。 +""" + +from typing import Any, Dict, List, Optional + +from kilostar.utils.ray_hook import ray_actor_hook + + +async def query_task_list( + user_id: str, + status_filter: Optional[str] = None, + limit: int = 20, +) -> Dict[str, Any]: + """列出当前用户的工作流任务。 + + Args: + user_id: 用户 ID(通常由调用方从对话上下文中带入) + status_filter: 可选,按状态过滤(pending/running/completed/failed) + limit: 最多返回条数,默认 20 + + Returns: + { + "user_id": str, + "tasks": [ + {"trace_id": ..., "title": ..., "status": ..., "command": ..., "created_at": ...} + ], + "total": int + } + """ + pg = ray_actor_hook("postgres_database").postgres_database + workflows = await pg.list_workflows.remote(user_id) or [] + + tasks: List[Dict[str, Any]] = [] + for wf in workflows: + status = getattr(wf, "status", None) + if status_filter and status != status_filter: + continue + tasks.append( + { + "trace_id": getattr(wf, "trace_id", None), + "title": getattr(wf, "title", None), + "status": status, + "command": getattr(wf, "command", None), + "created_at": str(getattr(wf, "created_at", "")), + } + ) + if len(tasks) >= limit: + break + + return { + "user_id": user_id, + "tasks": tasks, + "total": len(tasks), + } diff --git a/data/toolset/regulatory_toolset/query_workflow_status.py b/data/toolset/regulatory_toolset/query_workflow_status.py new file mode 100644 index 0000000..b23840e --- /dev/null +++ b/data/toolset/regulatory_toolset/query_workflow_status.py @@ -0,0 +1,51 @@ +"""query_workflow_status:查询某个 trace_id 对应工作流的最近事件。 + +regulatory_node 在与用户对话时,可以借此工具回答"我那个任务跑到哪一步了" +之类的问题。返回最近 N 条事件 + 当前工作流 status。 +""" + +from typing import Any, Dict, List + +from kilostar.utils.ray_hook import ray_actor_hook + + +async def query_workflow_status(trace_id: str, limit: int = 10) -> Dict[str, Any]: + """查询指定工作流 trace_id 的状态与最近事件。 + + Args: + trace_id: 工作流追踪 ID + limit: 返回最近多少条事件,默认 10 + + Returns: + { + "trace_id": str, + "status": str | None, # 工作流当前状态(pending/running/completed/failed) + "title": str | None, + "recent_events": [ # 最近事件,按时间倒序 + {"event_type": ..., "level": ..., "message": ..., "node_name": ..., "created_at": ...} + ] + } + """ + pg = ray_actor_hook("postgres_database").postgres_database + + workflow = await pg.get_workflow.remote(trace_id) + events = await pg.query_event_logs.remote(trace_id=trace_id, limit=limit) + + recent: List[Dict[str, Any]] = [] + for e in events or []: + recent.append( + { + "event_type": getattr(e, "event_type", None), + "level": getattr(e, "level", None), + "message": getattr(e, "message", None), + "node_name": getattr(e, "node_name", None), + "created_at": str(getattr(e, "created_at", "")), + } + ) + + return { + "trace_id": trace_id, + "status": getattr(workflow, "status", None) if workflow else None, + "title": getattr(workflow, "title", None) if workflow else None, + "recent_events": recent, + } diff --git a/data/toolset/regulatory_toolset/send_file.py b/data/toolset/regulatory_toolset/send_file.py new file mode 100644 index 0000000..89aaa68 --- /dev/null +++ b/data/toolset/regulatory_toolset/send_file.py @@ -0,0 +1,63 @@ +"""send_file:在对话/工作流场景下投递一份文件给用户。 + +regulatory_node 直接对话场景下用此工具把生成的文件发给用户: +- 工作流场景(带 trace_id):写入 data/artifact//,前端通过 SSE + 收到带下载链接的卡片。 +- 直接对话场景(无 trace_id):退化为把文件内容拼回字符串返回给 agent, + 让 agent 再以代码块形式吐给用户。 +""" + +import json +import re +import uuid +from pathlib import Path + +from kilostar.utils.ray_hook import ray_actor_hook +from kilostar.utils.settings import get_artifact_dir + + +_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") + + +def _sanitize_filename(name: str) -> str: + name = name.strip().replace("\\", "/").split("/")[-1] + name = _SAFE_NAME_RE.sub("_", name) + return name or "file" + + +async def send_file(filename: str, content: str, trace_id: str = "") -> str: + """把 agent 生成的文件作为附件投递给用户。 + + 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```" + + safe_name = _sanitize_filename(filename) + artifact_id = uuid.uuid4().hex[:12] + trace_dir: Path = get_artifact_dir() / trace_id + trace_dir.mkdir(parents=True, exist_ok=True) + file_path = trace_dir / f"{artifact_id}_{safe_name}" + file_path.write_text(content, encoding="utf-8") + + payload = json.dumps( + { + "type": "file", + "filename": safe_name, + "artifact_id": artifact_id, + "url": f"/api/v1/resource/artifact/{trace_id}/{artifact_id}", + "size": len(content.encode("utf-8")), + }, + 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"已发送文件: {safe_name}" diff --git a/frontend/src/components/Agent/ProvidersSettings.tsx b/frontend/src/components/Agent/ProvidersSettings.tsx index 5c3dfdc..ca92ded 100644 --- a/frontend/src/components/Agent/ProvidersSettings.tsx +++ b/frontend/src/components/Agent/ProvidersSettings.tsx @@ -37,13 +37,14 @@ export function ProvidersSettings() { const [isModalOpen, setIsModalOpen] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const [selectedTypeId, setSelectedTypeId] = useState('openai'); - const [formData, setFormData] = useState({ provider_title: '', provider_url: '', provider_apikey: '', custom_models: '' }); + const [formData, setFormData] = useState({ provider_title: '', provider_url: '', provider_apikey: '', custom_models: '', model_settings: '' }); const [showAdvanced, setShowAdvanced] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [testLoading, setTestLoading] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; error?: string; model_count?: number } | null>(null); const [error, setError] = useState(''); const [expandedProvider, setExpandedProvider] = useState(null); + const [modelSettingsError, setModelSettingsError] = useState(''); const selectedType = PROVIDER_TYPES.find((p) => p.id === selectedTypeId) || PROVIDER_TYPES[0]; @@ -65,8 +66,9 @@ export function ProvidersSettings() { const openAddModal = () => { setEditingProvider(null); setSelectedTypeId('openai'); - setFormData({ provider_title: '', provider_url: PROVIDER_TYPES[0].defaultUrl, provider_apikey: '', custom_models: '' }); + setFormData({ provider_title: '', provider_url: PROVIDER_TYPES[0].defaultUrl, provider_apikey: '', custom_models: '', model_settings: '' }); setError(''); + setModelSettingsError(''); setTestResult(null); setShowAdvanced(false); setIsModalOpen(true); @@ -81,8 +83,12 @@ export function ProvidersSettings() { provider_url: provider.provider_url || '', provider_apikey: '', custom_models: '', + model_settings: provider.model_settings && Object.keys(provider.model_settings).length > 0 + ? JSON.stringify(provider.model_settings, null, 2) + : '', }); setError(''); + setModelSettingsError(''); setTestResult(null); setShowAdvanced(false); setIsModalOpen(true); @@ -97,6 +103,29 @@ export function ProvidersSettings() { setTestResult(null); }; + const parseModelSettings = (): { ok: true; value: Record> | undefined } | { ok: false } => { + const raw = formData.model_settings.trim(); + if (!raw) return { ok: true, value: undefined }; + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + setModelSettingsError(t('agent.providerModelSettingsInvalid')); + return { ok: false }; + } + for (const v of Object.values(parsed)) { + if (typeof v !== 'object' || v === null || Array.isArray(v)) { + setModelSettingsError(t('agent.providerModelSettingsInvalid')); + return { ok: false }; + } + } + setModelSettingsError(''); + return { ok: true, value: parsed as Record> }; + } catch { + setModelSettingsError(t('agent.providerModelSettingsInvalid')); + return { ok: false }; + } + }; + const buildPayload = () => { const customModels = formData.custom_models .split(',').map((s) => s.trim()).filter(Boolean); @@ -107,6 +136,8 @@ export function ProvidersSettings() { provider_apikey: formData.provider_apikey, }; if (customModels.length > 0) payload.custom_models = customModels; + const ms = parseModelSettings(); + if (ms.ok && ms.value) payload.model_settings = ms.value; return payload; }; @@ -134,6 +165,8 @@ export function ProvidersSettings() { setError(t('agent.providerFillAll')); return; } + const ms = parseModelSettings(); + if (!ms.ok) return; setSubmitLoading(true); setError(''); try { @@ -361,6 +394,31 @@ export function ProvidersSettings() { />

{t('agent.providerCustomModelsHint')}

+ +
+ +