ソースを参照

feat: Claude Code integration

Talegorithm 1 ヶ月 前
コミット
ddf0917d22

+ 68 - 0
.claude/skills/agent/SKILL.md

@@ -0,0 +1,68 @@
+---
+name: agent
+description: 调用 Agent 执行任务 —— 能从知识库获取内容制作经验、擅长使用浏览器做深度调研。
+---
+
+# Agent 调用
+
+统一入口调用远端(KnowHub 服务器)或本地 Agent。底层走 `cyber-agent` SDK 的 `invoke_agent()`。
+
+## 前置
+
+需要 `cyber-agent` 包可 import。若未安装,在该仓库根目录执行:
+
+```bash
+pip install -e .
+```
+
+## 用法
+
+```bash
+python <this_skill_dir>/invoke.py \
+    --agent_type=<type> \
+    --task="<任务描述>" \
+    [--skills=skill1,skill2] \
+    [--continue_from=<sub_trace_id>] \
+    [--project_root=<本地项目目录>]
+```
+
+## 远端 Agent(`remote_` 前缀)
+
+**`remote_librarian`** — 知识库查询与上传:
+- `skills=ask_strategy`(默认):查询整合,返回带引用的回答
+- `skills=upload_strategy`:上传(`task` 为 JSON 字符串 `{knowledge:[...], tools:[...], resources:[...]}`)
+
+**`remote_research`** — 深度调研:自动全网搜集 + 总结,成果自动入库。
+
+## 本地 Agent
+
+`agent_type` 无 `remote_` 前缀,需 `--project_root` 指向项目目录。约定项目结构:
+- `config.py` 定义 `RUN_CONFIG`(必需)
+- `presets.json` 定义 preset(可选)
+- `tools/__init__.py` 注册自定义工具(可选)
+
+## 续跑
+
+首次调用返回的 `sub_trace_id` 作为下次的 `--continue_from`,同一 Agent 累积上下文。
+
+## 返回值
+
+stdout 输出 JSON,成功退出码 0、失败 1:
+
+```json
+{
+  "mode": "remote" | "local",
+  "agent_type": "...",
+  "sub_trace_id": "...",
+  "status": "completed" | "failed",
+  "summary": "Agent 最终产出的 message 文本(结构化信息由 prompt 约定写在这里)",
+  "stats": {"total_messages": N, "total_tokens": N, "total_cost": 0.xxx},
+  "error": null
+}
+```
+
+## 注意
+
+- 远端 `skills` 由服务器白名单过滤,非法项静默丢弃
+- 远端 Agent 无法访问调用方本地文件系统——大数据先通过 knowhub / toolhub 上传再传 ID
+- `summary` 是纯文本;需要结构化字段(引用来源、ID 等)时由 Agent prompt 约定格式,调用方自己 parse

+ 47 - 0
.claude/skills/agent/invoke.py

@@ -0,0 +1,47 @@
+#!/usr/bin/env python
+"""
+薄 CLI 脚本:透传命令行参数到 cyber-agent 的 invoke_agent() SDK。
+远端 / 本地由 agent_type 前缀决定,同步返回 JSON 到 stdout。
+"""
+
+import argparse
+import asyncio
+import json
+import sys
+
+from agent import invoke_agent
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="调用 Agent(远端或本地)")
+    parser.add_argument("--agent_type", required=True,
+                        help="Agent 类型。'remote_' 前缀走 KnowHub 服务器;否则本地执行")
+    parser.add_argument("--task", required=True, help="任务描述")
+    parser.add_argument("--skills", default=None,
+                        help="逗号分隔的 skill 列表(如 ask_strategy,upload_strategy)")
+    parser.add_argument("--continue_from", default=None,
+                        help="已有 sub_trace_id,传入则续跑")
+    parser.add_argument("--messages", default=None,
+                        help="预置消息(JSON 数组字符串,OpenAI 格式)")
+    parser.add_argument("--project_root", default=None,
+                        help="本地 agent 必填——项目目录(含 config.py)")
+    args = parser.parse_args()
+
+    skills = [s.strip() for s in args.skills.split(",") if s.strip()] if args.skills else None
+    messages = json.loads(args.messages) if args.messages else None
+
+    result = asyncio.run(invoke_agent(
+        agent_type=args.agent_type,
+        task=args.task,
+        skills=skills,
+        continue_from=args.continue_from,
+        messages=messages,
+        project_root=args.project_root,
+    ))
+
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0 if result.get("status") == "completed" else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 4 - 1
.gitignore

@@ -37,7 +37,10 @@ env/
 *.swo
 *~
 CLAUDE.md
-.claude/
+# 默认忽略 .claude/ 下所有子项(IDE 状态、session 等)
+.claude/*
+# 但保留项目级 Claude Code skill(版本化)
+!.claude/skills/
 
 # Testing
 .pytest_cache/

+ 53 - 0
agent/README.md

@@ -207,6 +207,59 @@ examples/research/
 
 ---
 
+## Claude Code 集成
+
+本仓库自带项目级 Claude Code skill:[`.claude/skills/agent/`](../.claude/skills/agent/)
+
+- `SKILL.md` — skill 元数据(description 给 Claude Code 路由用)
+- `invoke.py` — 20 行薄脚本,`from agent import invoke_agent` 然后透传命令行参数
+
+### 在本仓库使用(自动激活)
+
+Claude Code 在本仓库目录下启动时自动加载 `.claude/skills/` 下的 skill。前提是 `cyber-agent` 包可 import:
+
+```bash
+pip install -e .
+```
+
+### 在其他项目使用
+
+把 skill symlink 或复制到用户级目录:
+
+```bash
+ln -s "$(pwd)/.claude/skills/agent" ~/.claude/skills/agent
+```
+
+之后任何 Claude Code session(无论 cwd 在哪)都能调用。
+
+### SDK 入口
+
+Claude Code skill 脚本最终调用 `agent/client.py::invoke_agent`,该函数也是公开 SDK 供任何 Python 代码复用:
+
+```python
+from agent import invoke_agent
+
+# 远端:查询知识库
+result = await invoke_agent(
+    agent_type="remote_librarian",
+    task="ControlNet 相关的工具知识",
+    skills=["ask_strategy"],
+)
+
+# 本地:在项目目录下跑 agent(约定 project_root/config.py 定义 RUN_CONFIG)
+result = await invoke_agent(
+    agent_type="main",
+    task="...",
+    project_root="./examples/research",
+)
+```
+
+路由规则:`agent_type.startswith("remote_")` → HTTP 调用 KnowHub `/api/agent`;否则在当前进程起 `AgentRunner`。
+
+**实现**:`agent/client.py:invoke_agent` / `.claude/skills/agent/invoke.py`
+
+---
+
 ## 附录:工具分组
 
 通过 `RunConfig(tool_groups=[...])` 控制 Agent 可用的工具范围。默认仅 `["core"]`。每个工具在 `@tool(groups=[...])` 中声明分组,支持多标签。

+ 5 - 0
agent/__init__.py

@@ -27,6 +27,9 @@ from agent.skill.models import Skill
 from agent.tools import tool, ToolRegistry, get_tool_registry
 from agent.tools.models import ToolResult, ToolContext
 
+# SDK 公开入口:统一调用 remote / 本地 Agent
+from agent.client import invoke_agent
+
 __version__ = "0.3.0"
 
 __all__ = [
@@ -59,4 +62,6 @@ __all__ = [
     "get_tool_registry",
     "ToolResult",
     "ToolContext",
+    # SDK
+    "invoke_agent",
 ]

+ 216 - 0
agent/client.py

@@ -0,0 +1,216 @@
+"""
+Agent SDK 入口 —— 统一调用 remote / 本地 Agent 的公开 API。
+
+使用方式(任何进程,只要装了 cyber-agent 包):
+
+    import asyncio
+    from agent import invoke_agent
+
+    # 远端(HTTP 调用 KnowHub 服务器)
+    result = asyncio.run(invoke_agent(
+        agent_type="remote_librarian",
+        task="ControlNet 相关的工具知识",
+        skills=["ask_strategy"],
+    ))
+
+    # 本地(在当前进程起 AgentRunner)
+    result = asyncio.run(invoke_agent(
+        agent_type="deconstruct",
+        task="...",
+        project_root="./examples/production_plan",
+    ))
+
+skill 脚本只需要 `from agent import invoke_agent` 然后透传命令行参数即可——
+不依赖仓库相对路径,也不会触发 `agent.tools.builtin` 的 eager tool registry 加载。
+"""
+
+import importlib
+import importlib.util
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+async def invoke_agent(
+    agent_type: str,
+    task: str,
+    skills: Optional[List[str]] = None,
+    continue_from: Optional[str] = None,
+    messages: Optional[List[Dict[str, Any]]] = None,
+    project_root: Optional[str] = None,
+) -> Dict[str, Any]:
+    """
+    统一调用远端或本地 Agent。
+
+    Args:
+        agent_type: Agent 类型。以 "remote_" 开头 → HTTP 调用 KnowHub;否则本地执行。
+        task: 任务描述
+        skills: 指定 skill 列表(远端由服务器白名单过滤;本地覆盖项目 RUN_CONFIG.skills)
+        continue_from: 已有 sub_trace_id,传入则续跑
+        messages: 预置 OpenAI 格式消息(远端 1D、本地 1D)
+        project_root: 本地 agent 必填——项目目录(含 config.py / presets.json / tools/)
+
+    Returns:
+        {"mode", "agent_type", "sub_trace_id", "status", "summary", "stats", "error"?}
+    """
+    if agent_type.startswith("remote_"):
+        # 懒 import,避免加载整个 tool registry(远端调用只需要 httpx)
+        from agent.tools.builtin.subagent import _run_remote_agent
+        return await _run_remote_agent(
+            agent_type=agent_type,
+            task=task,
+            messages=messages,
+            continue_from=continue_from,
+            skills=skills,
+        )
+
+    if not project_root:
+        return {
+            "mode": "local",
+            "agent_type": agent_type,
+            "status": "failed",
+            "error": "本地 agent 需要 project_root 指定项目目录(含 config.py)",
+        }
+
+    return await _run_local_agent(
+        agent_type=agent_type,
+        task=task,
+        skills=skills,
+        continue_from=continue_from,
+        messages=messages,
+        project_root=project_root,
+    )
+
+
+async def _run_local_agent(
+    agent_type: str,
+    task: str,
+    skills: Optional[List[str]],
+    continue_from: Optional[str],
+    messages: Optional[List[Dict[str, Any]]],
+    project_root: str,
+) -> Dict[str, Any]:
+    """
+    在当前进程中起 AgentRunner 跑本地 agent。
+
+    项目目录约定:
+      project_root/
+      ├── config.py          # 必需:定义 RUN_CONFIG(RunConfig 实例),可选 SKILLS_DIR / TRACE_STORE_PATH
+      ├── presets.json       # 可选:agent_type preset
+      └── tools/             # 可选:项目自定义工具(有 __init__.py 则 import 触发 @tool 注册)
+
+    .env 会自动从 project_root 或上两级目录查找并加载。
+    """
+    root = Path(project_root).resolve()
+    if not root.is_dir():
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"project_root 不存在: {root}"}
+
+    # 1. 把项目根加入 sys.path(让 `import config` / `import tools` 能找到)
+    if str(root) not in sys.path:
+        sys.path.insert(0, str(root))
+
+    # 2. 加载 .env:依次查项目根、两级父目录(兼容 monorepo)、cyber-agent 仓库根
+    try:
+        from dotenv import load_dotenv
+        import agent as _agent_pkg
+        agent_repo_root = Path(_agent_pkg.__file__).parent.parent
+        for candidate in (
+            root / ".env",
+            root.parent / ".env",
+            root.parent.parent / ".env",
+            agent_repo_root / ".env",
+        ):
+            if candidate.exists():
+                load_dotenv(candidate)
+                break
+    except ImportError:
+        pass
+
+    # 3. 加载项目 config
+    config_path = root / "config.py"
+    if not config_path.exists():
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"缺少 {config_path}"}
+
+    try:
+        spec = importlib.util.spec_from_file_location("_project_config", config_path)
+        cfg_mod = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(cfg_mod)
+    except Exception as e:
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"加载 {config_path} 失败: {e}"}
+
+    run_config = getattr(cfg_mod, "RUN_CONFIG", None)
+    if run_config is None:
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"{config_path} 未定义 RUN_CONFIG"}
+
+    # 覆盖 agent_type / skills / continue_from
+    run_config.agent_type = agent_type
+    if skills is not None:
+        run_config.skills = skills
+    if continue_from:
+        run_config.trace_id = continue_from
+
+    # 4. 加载项目 presets.json(如果有)
+    presets_path = root / "presets.json"
+    if presets_path.exists():
+        try:
+            from agent.core.presets import load_presets_from_json
+            load_presets_from_json(str(presets_path))
+        except Exception as e:
+            logger.warning(f"加载 presets.json 失败: {e}")
+
+    # 5. 触发项目自定义工具注册(约定:project_root/tools/__init__.py)
+    if (root / "tools" / "__init__.py").exists():
+        try:
+            importlib.import_module("tools")
+        except Exception as e:
+            logger.warning(f"加载 tools 包失败: {e}")
+
+    # 6. 创建 AgentRunner
+    from agent.core.runner import AgentRunner
+    from agent.trace import FileSystemTraceStore
+    from agent.llm import create_qwen_llm_call
+
+    trace_store_path = getattr(cfg_mod, "TRACE_STORE_PATH", ".trace")
+    skills_dir = getattr(cfg_mod, "SKILLS_DIR", "./skills")
+
+    # 相对路径以 project_root 为基准
+    if not os.path.isabs(trace_store_path):
+        trace_store_path = str(root / trace_store_path)
+    if not os.path.isabs(skills_dir):
+        skills_dir = str(root / skills_dir)
+
+    runner = AgentRunner(
+        trace_store=FileSystemTraceStore(base_path=trace_store_path),
+        llm_call=create_qwen_llm_call(model=run_config.model),
+        skills_dir=skills_dir,
+    )
+
+    # 7. 构建消息
+    msgs = list(messages) if messages else []
+    msgs.append({"role": "user", "content": task})
+
+    # 8. 运行
+    try:
+        result = await runner.run_result(messages=msgs, config=run_config)
+    except Exception as e:
+        logger.exception("本地 agent 运行失败")
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"{type(e).__name__}: {e}"}
+
+    return {
+        "mode": "local",
+        "agent_type": agent_type,
+        "sub_trace_id": result.get("trace_id"),
+        "status": result.get("status", "unknown"),
+        "summary": result.get("summary", ""),
+        "stats": result.get("stats", {}),
+        "error": result.get("error"),
+    }

+ 1 - 1
agent/docs/decisions.md

@@ -1353,7 +1353,7 @@ context = {
 6. **Skill 白名单机制**:远端每个 `agent_type` 定义 `ALLOWED_SKILLS`,调用方传的 skill 经白名单过滤。其他配置(`tools`/`model`/prompt)仍然纯服务器端。
 7. IO 契约:不发明 per-agent-type schema——Agent 之间通过 message 交流,结构化信息由 Agent 的 prompt 约定写进 message 文本,caller 自己 parse。
 8. 续跑:服务器不维护 `caller_trace_id → sub_trace_id` 映射;caller 显式传 `continue_from`。
-9. CLI:`python -m agent.tools.builtin.subagent --agent_type=remote_xxx --task=... [--skills=a,b]` 让 Claude Code 可通过 skill 调用
+9. SDK 入口:`agent.invoke_agent()`(`agent/client.py`),统一路由 remote/本地。对应 Claude Code skill 薄脚本 `~/.claude/skills/agent/invoke.py` 透传 CLI 参数。**不再**用 `python -m agent.tools.builtin.subagent`——该路径触发 double-import bug(`agent.tools.builtin.__init__.py` 传递 import + runpy 作为 `__main__` 重载,环境变量在两次加载之间被污染)
 10. 远端 Agent 的三条安全约束,每个 handler 直接在自己的 `RunConfig` 里配置(不搞抽象 helper):
     - 禁止调用 `agent` / `evaluate`(防递归)——用 `tools=[...]` 精确列表 或 `exclude_tools=["agent","evaluate"]`
     - 关闭自动知识提取 / 复盘(`enable_extraction` / `enable_completion_extraction` = False)

+ 15 - 14
agent/docs/tools.md

@@ -973,24 +973,25 @@ async def agent(
 
 **返回值**:本地和远端统一返回 `{status, sub_trace_id, summary, stats}`。`summary` 是 Agent 最终产出的 message 文本——Agent 之间通过 message 通信,不定义 per-agent 的返回 schema。如果需要结构化输出(引用来源、ID 列表等),由 Agent 的 prompt 约定写进 message 文本,调用方自行 parse。
 
-#### CLI 使
+#### SDK / CLI 调
 
-`agent` 工具支持命令行调用,主要用于远端 Agent(本地 Agent 需要父 trace 上下文,不适合 CLI)。实现:`agent/tools/builtin/subagent.py` 的 `__main__` 入口。
+公开 SDK 入口:`agent.invoke_agent()`(定义在 `agent/client.py`),和 `agent` 工具签名一致,路由规则相同。任何 Python 进程只要装了 `cyber-agent` 包就能调用:
 
-```bash
-# 首次调用
-python -m agent.tools.builtin.subagent \
-    --agent_type=remote_librarian \
-    --task="ControlNet 相关的工具知识"
-# 返回 JSON,包含 sub_trace_id
-
-# 续跑
-python -m agent.tools.builtin.subagent \
-    --agent_type=remote_librarian \
-    --task="再补充 IP-Adapter 的" \
-    --continue_from=<sub_trace_id>
+```python
+import asyncio
+from agent import invoke_agent
+
+result = asyncio.run(invoke_agent(
+    agent_type="remote_librarian",
+    task="ControlNet 相关的工具知识",
+    skills=["ask_strategy"],
+))
 ```
 
+本地 agent 调用需额外传 `project_root`,SDK 会按约定读项目的 `config.py`(`RUN_CONFIG`)、`presets.json`、`tools/` 包。
+
+配套一个 Claude Code skill 薄脚本在 `~/.claude/skills/agent/invoke.py`,透传命令行参数到 `invoke_agent()`,stdout 输出 JSON 结果。让 Claude Code 用同一套语义驱动 Agent。
+
 基于此 CLI,项目在 `~/.claude/skills/` 或项目 skills 目录可以注册 user skill,让 Claude Code 也能通过 skill 调用远端 Agent——模式和 `toolhub` / `knowhub` skill 一致。
 
 #### `agent_type` 与 Presets

+ 0 - 47
agent/tools/builtin/subagent.py

@@ -930,50 +930,3 @@ async def evaluate(
             "error": error_msg,
             "sub_trace_id": sub_trace_id,
         }
-
-
-# ===== CLI 入口(仅支持远端 agent;本地需要父 trace 上下文,不适合 CLI)=====
-
-if __name__ == "__main__":
-    import argparse
-    import json as _json
-    import sys as _sys
-
-    parser = argparse.ArgumentParser(
-        description="调用远端 Agent(KnowHub 服务器端 /api/agent)",
-    )
-    parser.add_argument("--agent_type", required=True,
-                        help="远端 Agent 类型,必须以 'remote_' 开头(如 remote_librarian, remote_research)")
-    parser.add_argument("--task", required=True, help="任务描述")
-    parser.add_argument("--continue_from", default=None,
-                        help="已有 sub_trace_id,传入则续跑该 trace")
-    parser.add_argument("--messages", default=None,
-                        help="预置消息(JSON 字符串,OpenAI 格式的 list of dict)")
-    parser.add_argument("--skills", default=None,
-                        help="指定 skill 列表,逗号分隔(如 ask_strategy,upload_strategy);服务器按白名单过滤")
-    args = parser.parse_args()
-
-    if not args.agent_type.startswith(REMOTE_PREFIX):
-        print(f"错误:agent_type 必须以 '{REMOTE_PREFIX}' 开头,收到: {args.agent_type}",
-              file=_sys.stderr)
-        _sys.exit(2)
-
-    msgs = None
-    if args.messages:
-        try:
-            msgs = _json.loads(args.messages)
-        except _json.JSONDecodeError as e:
-            print(f"错误:--messages 不是合法 JSON: {e}", file=_sys.stderr)
-            _sys.exit(2)
-
-    skills_list = [s.strip() for s in args.skills.split(",") if s.strip()] if args.skills else None
-
-    result = asyncio.run(_run_remote_agent(
-        agent_type=args.agent_type,
-        task=args.task,
-        messages=msgs,
-        continue_from=args.continue_from,
-        skills=skills_list,
-    ))
-    print(_json.dumps(result, ensure_ascii=False, indent=2))
-    _sys.exit(0 if result.get("status") == "completed" else 1)

+ 1 - 1
knowhub/docs/remote-agents.md

@@ -128,7 +128,7 @@ KnowHub 服务器托管的远端 Agent,供客户端通过统一的 `agent` 工
 | Prompt | `knowhub/agents/librarian_agent.prompt` / `research_agent.prompt` |
 | Server 端点 | `knowhub/server.py::agent_api`(`/api/agent`)+ 运维 `/api/knowledge/upload/{pending,retry}` |
 | 客户端入口 | `agent/tools/builtin/subagent.py::agent`(按 `remote_` 前缀路由) |
-| CLI | `python -m agent.tools.builtin.subagent --agent_type=remote_xxx --task=...` |
+| SDK / CLI | `agent.invoke_agent()` (`agent/client.py`);Claude Code skill 脚本 `~/.claude/skills/agent/invoke.py` 透传参数 |
 
 ---