Skip to content

Commit 34d1c0f

Browse files
committed
feat(p8.4): agentic runner 启动模式 - 复用 mcp_tools + Claude Agent SDK
新增 server/agentic_runner.py: - AgenticConfig: goal / model / max_iterations / allowed_tools / dry_run - AgenticResult: 成功标志 + 迭代数 + tool_calls + 错误信息 - is_agentic_available(): 探测 ANTHROPIC_API_KEY + claude-agent-sdk 是否就绪 - run_agentic(): 主入口,SDK 缺失时优雅退出而非异常 - 复用 database.mcp_tools 工具注册表,zero duplication - dry_run 模式不调 LLM,只打印 plan (CI / 配置校验用) 启动模式对比: - mcp_server.py: 经典 MCP server,被动等待客户端调用 - agentic_runner.py: 主动 agent,内嵌 SDK,自己规划/调用/迭代 13 个 unit test 覆盖配置校验 / 可用性探测 / dry_run / 工具过滤 / 不可用降级。 不依赖真实 ANTHROPIC_API_KEY 或 SDK 安装。
1 parent cdf0612 commit 34d1c0f

2 files changed

Lines changed: 284 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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}

tests/unit/test_agentic_runner.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Unit tests for server.agentic_runner."""
2+
import os
3+
4+
import pytest
5+
6+
from dbjavagenix.server.agentic_runner import (
7+
AgenticConfig,
8+
AgenticResult,
9+
_filter_tools,
10+
is_agentic_available,
11+
run_agentic,
12+
)
13+
14+
15+
class TestAgenticConfig:
16+
def test_minimal_valid(self):
17+
c = AgenticConfig(goal="analyze schema")
18+
assert c.goal == "analyze schema"
19+
assert c.model == "claude-opus-4-7"
20+
assert c.max_iterations == 20
21+
assert c.allowed_tools is None
22+
assert c.dry_run is False
23+
24+
def test_empty_goal_raises(self):
25+
with pytest.raises(ValueError, match="goal"):
26+
AgenticConfig(goal="")
27+
28+
def test_non_string_goal_raises(self):
29+
with pytest.raises(ValueError):
30+
AgenticConfig(goal=123) # type: ignore
31+
32+
def test_max_iter_too_low(self):
33+
with pytest.raises(ValueError, match="max_iterations"):
34+
AgenticConfig(goal="x", max_iterations=0)
35+
36+
def test_max_iter_too_high(self):
37+
with pytest.raises(ValueError):
38+
AgenticConfig(goal="x", max_iterations=101)
39+
40+
41+
class TestAvailability:
42+
def test_no_api_key_returns_false(self, monkeypatch):
43+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
44+
ok, reason = is_agentic_available()
45+
assert ok is False
46+
assert "ANTHROPIC_API_KEY" in reason
47+
48+
49+
class TestFilterTools:
50+
def test_none_passes_all(self):
51+
r = {"a": 1, "b": 2}
52+
assert _filter_tools(r, None) == r
53+
54+
def test_empty_list_passes_all(self):
55+
r = {"a": 1, "b": 2}
56+
assert _filter_tools(r, []) == r
57+
58+
def test_filter_to_subset(self):
59+
r = {"a": 1, "b": 2, "c": 3}
60+
assert _filter_tools(r, ["a", "c"]) == {"a": 1, "c": 3}
61+
62+
def test_unknown_name_silently_dropped(self):
63+
r = {"a": 1}
64+
assert _filter_tools(r, ["a", "nonexistent"]) == {"a": 1}
65+
66+
67+
class TestDryRun:
68+
"""dry_run 路径不需要真实 SDK,只验证配置和工具数量。"""
69+
70+
def test_dry_run_with_key_succeeds(self, monkeypatch):
71+
# dry_run 走 is_agentic_available 前置,需要 key 才能进 dry_run 分支
72+
# 但 SDK 可能没装。我们直接让 is_agentic_available mock 为 True 的
73+
# 路径靠 monkeypatch 实现。
74+
monkeypatch.setenv("ANTHROPIC_API_KEY", "fake-test-key")
75+
# 如果 sdk 没装 is_agentic_available 仍返回 False,我们 monkeypatch 它
76+
import dbjavagenix.server.agentic_runner as mod
77+
monkeypatch.setattr(mod, "is_agentic_available", lambda: (True, ""))
78+
79+
config = AgenticConfig(goal="test goal", dry_run=True)
80+
result = run_agentic(config, tool_registry_factory=lambda: {"tool_a": {}, "tool_b": {}})
81+
assert result.success is True
82+
assert result.iterations_used == 0
83+
assert "test goal" in result.final_message
84+
assert "tools=2" in result.final_message
85+
86+
def test_dry_run_respects_allowed_tools(self, monkeypatch):
87+
import dbjavagenix.server.agentic_runner as mod
88+
monkeypatch.setattr(mod, "is_agentic_available", lambda: (True, ""))
89+
90+
config = AgenticConfig(
91+
goal="x", dry_run=True, allowed_tools=["tool_a"]
92+
)
93+
result = run_agentic(config, tool_registry_factory=lambda: {"tool_a": {}, "tool_b": {}})
94+
assert result.success is True
95+
assert "tools=1" in result.final_message
96+
97+
98+
class TestUnavailable:
99+
def test_returns_error_result_when_unavailable(self, monkeypatch):
100+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
101+
config = AgenticConfig(goal="x")
102+
result = run_agentic(config)
103+
assert result.success is False
104+
assert result.error is not None
105+
assert "unavailable" in result.error

0 commit comments

Comments
 (0)