|
| 1 | +""" |
| 2 | +P8.4: Agentic runner mode using Claude Agent SDK. |
| 3 | +
|
| 4 | +启动模式对比: |
| 5 | +- `mcp_server.py`: 经典 MCP server,被动等待客户端调用 tools |
| 6 | +- `agentic_runner.py`: 主动 agent,内嵌 Claude Agent SDK,自己规划/调用/迭代 |
| 7 | +
|
| 8 | +用途: |
| 9 | +- 一次性批处理:"分析 host=X 的 schema,生成代码到 ./out" 单条命令完成全流程 |
| 10 | +- 离线/CI:无人值守生成 (需要 ANTHROPIC_API_KEY) |
| 11 | +- 教学 demo:让面试官看到"同样的 tools 既能被 Claude Desktop 用, |
| 12 | + 也能被独立 agent 用" |
| 13 | +
|
| 14 | +设计: |
| 15 | +- 复用 mcp_tools 的所有工具定义 (zero duplication) |
| 16 | +- Agent SDK 不可用 (没装 claude-agent-sdk 或没 ANTHROPIC_API_KEY) 时 |
| 17 | + 优雅退出并提示用户走 mcp_server 模式 |
| 18 | +- 不直接 import claude_agent_sdk - 用 lazy import,避免没装 SDK 的开发环境 |
| 19 | + import 项目就报错 |
| 20 | +""" |
| 21 | + |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import logging |
| 25 | +import os |
| 26 | +from dataclasses import dataclass |
| 27 | +from typing import Any, Callable, Optional |
| 28 | + |
| 29 | +logger = logging.getLogger(__name__) |
| 30 | + |
| 31 | + |
| 32 | +# ============================================================ |
| 33 | +# 可用性探测 |
| 34 | +# ============================================================ |
| 35 | + |
| 36 | +def is_agentic_available() -> tuple[bool, str]: |
| 37 | + """返回 (是否可用, 不可用原因)。 |
| 38 | +
|
| 39 | + 可用条件: |
| 40 | + 1. ANTHROPIC_API_KEY 环境变量存在 |
| 41 | + 2. claude_agent_sdk 包已安装 |
| 42 | + """ |
| 43 | + if not os.environ.get("ANTHROPIC_API_KEY"): |
| 44 | + return False, "ANTHROPIC_API_KEY not set" |
| 45 | + try: |
| 46 | + import claude_agent_sdk # noqa: F401 |
| 47 | + return True, "" |
| 48 | + except ImportError: |
| 49 | + return False, "claude-agent-sdk not installed (pip install claude-agent-sdk)" |
| 50 | + |
| 51 | + |
| 52 | +# ============================================================ |
| 53 | +# 配置 + 结果 |
| 54 | +# ============================================================ |
| 55 | + |
| 56 | +@dataclass |
| 57 | +class AgenticConfig: |
| 58 | + """Agentic runner 启动参数""" |
| 59 | + goal: str # 自然语言目标,例如 "分析 host=X 的 schema 并生成代码到 ./out" |
| 60 | + model: str = "claude-opus-4-7" |
| 61 | + max_iterations: int = 20 |
| 62 | + allowed_tools: Optional[list[str]] = None # None = 所有 tools |
| 63 | + dry_run: bool = False # True 时不真正调 LLM,只打印 plan |
| 64 | + |
| 65 | + def __post_init__(self) -> None: |
| 66 | + if not self.goal or not isinstance(self.goal, str): |
| 67 | + raise ValueError("goal must be non-empty string") |
| 68 | + if self.max_iterations < 1 or self.max_iterations > 100: |
| 69 | + raise ValueError("max_iterations must be in [1, 100]") |
| 70 | + |
| 71 | + |
| 72 | +@dataclass |
| 73 | +class AgenticResult: |
| 74 | + """Agent 跑完后的总结""" |
| 75 | + success: bool |
| 76 | + iterations_used: int = 0 |
| 77 | + final_message: str = "" |
| 78 | + tool_calls_made: int = 0 |
| 79 | + error: Optional[str] = None |
| 80 | + |
| 81 | + |
| 82 | +# ============================================================ |
| 83 | +# 主入口 |
| 84 | +# ============================================================ |
| 85 | + |
| 86 | +def run_agentic( |
| 87 | + config: AgenticConfig, |
| 88 | + tool_registry_factory: Callable[[], dict[str, Any]] | None = None, |
| 89 | +) -> AgenticResult: |
| 90 | + """启动 agentic runner。 |
| 91 | +
|
| 92 | + Args: |
| 93 | + config: 启动配置 |
| 94 | + tool_registry_factory: 返回 tool 注册表的工厂函数,默认用 dbjavagenix 自带 |
| 95 | + 的全套 tools。测试时可注入空 registry。 |
| 96 | +
|
| 97 | + Returns: |
| 98 | + AgenticResult: 跑完(成功 / 达到 max_iterations / 出错)的总结 |
| 99 | + """ |
| 100 | + available, reason = is_agentic_available() |
| 101 | + if not available: |
| 102 | + return AgenticResult( |
| 103 | + success=False, |
| 104 | + error=f"agentic mode unavailable: {reason}", |
| 105 | + ) |
| 106 | + |
| 107 | + if config.dry_run: |
| 108 | + # dry_run 不调 SDK,只把 plan 打出来。便于 CI 验证配置正确性 |
| 109 | + registry = tool_registry_factory() if tool_registry_factory else {} |
| 110 | + tool_count = len(config.allowed_tools or registry.keys()) |
| 111 | + return AgenticResult( |
| 112 | + success=True, |
| 113 | + iterations_used=0, |
| 114 | + final_message=( |
| 115 | + f"[dry-run] goal={config.goal!r} model={config.model} " |
| 116 | + f"max_iter={config.max_iterations} tools={tool_count}" |
| 117 | + ), |
| 118 | + tool_calls_made=0, |
| 119 | + ) |
| 120 | + |
| 121 | + # 真实启动 — lazy import 避免没装 SDK 时 import 此模块直接炸 |
| 122 | + try: |
| 123 | + from claude_agent_sdk import Agent # type: ignore |
| 124 | + except ImportError as e: # pragma: no cover - 被 is_agentic_available 拦住 |
| 125 | + return AgenticResult(success=False, error=f"import failed: {e}") |
| 126 | + |
| 127 | + registry = tool_registry_factory() if tool_registry_factory else _default_tool_registry() |
| 128 | + try: |
| 129 | + agent = Agent( |
| 130 | + model=config.model, |
| 131 | + tools=_filter_tools(registry, config.allowed_tools), |
| 132 | + max_iterations=config.max_iterations, |
| 133 | + ) |
| 134 | + result = agent.run(config.goal) |
| 135 | + return AgenticResult( |
| 136 | + success=True, |
| 137 | + iterations_used=getattr(result, "iterations", 0), |
| 138 | + final_message=getattr(result, "final_message", ""), |
| 139 | + tool_calls_made=getattr(result, "tool_calls", 0), |
| 140 | + ) |
| 141 | + except Exception as e: # noqa: BLE001 |
| 142 | + logger.error("agentic run failed: %s", e) |
| 143 | + return AgenticResult(success=False, error=str(e)) |
| 144 | + |
| 145 | + |
| 146 | +# ============================================================ |
| 147 | +# 工具注册表 (default = 复用 mcp_tools) |
| 148 | +# ============================================================ |
| 149 | + |
| 150 | +def _default_tool_registry() -> dict[str, Any]: |
| 151 | + """复用 mcp_server 的工具定义。lazy import 避免循环依赖""" |
| 152 | + try: |
| 153 | + from ..database import mcp_tools # type: ignore |
| 154 | + registry: dict[str, Any] = {} |
| 155 | + for getter in ( |
| 156 | + "get_connection_tools", |
| 157 | + "get_table_analysis_tools", |
| 158 | + "get_codegen_tools", |
| 159 | + "get_springboot_project_tools", |
| 160 | + ): |
| 161 | + fn = getattr(mcp_tools, getter, None) |
| 162 | + if fn is None: |
| 163 | + continue |
| 164 | + for tool in fn(): |
| 165 | + name = getattr(tool, "name", None) or ( |
| 166 | + tool.get("name") if isinstance(tool, dict) else None |
| 167 | + ) |
| 168 | + if name: |
| 169 | + registry[name] = tool |
| 170 | + return registry |
| 171 | + except Exception as e: # noqa: BLE001 |
| 172 | + logger.warning("default tool registry build failed: %s", e) |
| 173 | + return {} |
| 174 | + |
| 175 | + |
| 176 | +def _filter_tools(registry: dict[str, Any], allowed: Optional[list[str]]) -> dict[str, Any]: |
| 177 | + if not allowed: |
| 178 | + return registry |
| 179 | + return {k: v for k, v in registry.items() if k in allowed} |
0 commit comments