feat(agent): Jina 搜索改用标准 MCP SDK 接入
- agent/task_executor.py: Jina 搜索从手搓 httpx 改为官方 mcp SDK
(streamablehttp_client + ClientSession);工具经 OpenAI function-calling 暴露给模型
- agent/requirements.txt: +mcp==1.28.0;pydantic 2.9.2->2.13.4(mcp 要求 >=2.11)
- orchestrator/agent_launcher.py: JINA_API_KEY 经 per-swarm Secret 透传给 agent pod
(SENSITIVE_ENV_KEYS),不内联 PodSpec
- k8s/orchestrator-local.yaml: 本地部署清单(默认 in-pod 沙箱评估开关 + JINA_API_KEY)
沙箱保持默认 in-pod 方案,未引入 OpenSandbox。
影响范围: agent_swarm(agent/orchestrator) + Agent(新增 Jina MCP 工具)。
密钥经 k8s Secret 注入无明文。不影响 Manager 契约/计费/审计/发布链路。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit deb984ac38)
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
openai==1.55.3
|
||||
websockets==13.1
|
||||
pydantic==2.9.2
|
||||
pydantic==2.13.4
|
||||
python-dotenv==1.0.1
|
||||
prometheus-client==0.20.0
|
||||
# Standard MCP SDK — agent connects to Jina MCP (search/read_url) over StreamableHTTP and
|
||||
# exposes the tools to the model via OpenAI function-calling. Pulls httpx transitively.
|
||||
mcp==1.28.0
|
||||
|
||||
+103
-3
@@ -54,6 +54,11 @@ class TaskExecutor:
|
||||
self.usage = self._empty_usage()
|
||||
self.current_context: dict = {}
|
||||
|
||||
# Jina MCP — loaded once at startup; empty list means no tools available
|
||||
self.jina_api_key = os.getenv("JINA_API_KEY", "")
|
||||
self._jina_tools: list[dict] = [] # OpenAI-format tool schemas
|
||||
self._jina_tools_loaded = False
|
||||
|
||||
async def execute_task(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -466,12 +471,107 @@ Return ONLY the JSON, no other text."""
|
||||
"summary": f"Failed to execute: {e}",
|
||||
}
|
||||
|
||||
# Jina MCP endpoint (StreamableHTTP). Read-only web tools (search_web/read_url/…).
|
||||
_JINA_MCP_URL = "https://mcp.jina.ai/v1"
|
||||
|
||||
def _jina_mcp_headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.jina_api_key}"}
|
||||
|
||||
async def _load_jina_tools(self) -> list[dict]:
|
||||
"""Fetch tool schemas from Jina MCP via the standard `mcp` SDK (StreamableHTTP transport).
|
||||
Cached after first call. Returns OpenAI function-calling tool specs. The SDK handles the
|
||||
MCP handshake, SSE framing, and session — no hand-rolled JSON-RPC/SSE parsing."""
|
||||
if self._jina_tools_loaded:
|
||||
return self._jina_tools
|
||||
self._jina_tools_loaded = True
|
||||
if not self.jina_api_key:
|
||||
return []
|
||||
try:
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
async with streamablehttp_client(
|
||||
self._JINA_MCP_URL, headers=self._jina_mcp_headers()
|
||||
) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
tools = (await session.list_tools()).tools
|
||||
self._jina_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.name,
|
||||
"description": t.description or "",
|
||||
"parameters": t.inputSchema or {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
logger.info("Loaded %d Jina MCP tools (mcp SDK)", len(self._jina_tools))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load Jina MCP tools: %s", exc)
|
||||
self._jina_tools = []
|
||||
return self._jina_tools
|
||||
|
||||
async def _call_jina_tool(self, tool_name: str, arguments: dict) -> str:
|
||||
"""Invoke a single Jina MCP tool via the standard `mcp` SDK and return its text result."""
|
||||
try:
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
async with streamablehttp_client(
|
||||
self._JINA_MCP_URL, headers=self._jina_mcp_headers()
|
||||
) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool(tool_name, arguments)
|
||||
parts = [
|
||||
c.text for c in result.content
|
||||
if getattr(c, "type", None) == "text"
|
||||
]
|
||||
return "\n".join(parts) or str(result.content)
|
||||
except Exception as exc:
|
||||
return f"[tool error: {exc}]"
|
||||
|
||||
async def _complete(self, prompt: str, max_tokens: int) -> str:
|
||||
"""Call the LLM with optional Jina MCP tools; handles the tool-call loop."""
|
||||
extra_headers = self._model_attribution_headers()
|
||||
tools = await self._load_jina_tools()
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
for _ in range(8): # max 8 tool-call rounds
|
||||
kwargs: dict = dict(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
extra_headers=extra_headers or None,
|
||||
)
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
kwargs["tool_choice"] = "auto"
|
||||
response = await self.client.chat.completions.create(**kwargs)
|
||||
self._record_openai_usage(response)
|
||||
msg = response.choices[0].message
|
||||
|
||||
if not msg.tool_calls:
|
||||
return msg.content or ""
|
||||
|
||||
# Execute each tool call and feed results back
|
||||
messages.append(msg.model_dump(exclude_unset=True))
|
||||
for tc in msg.tool_calls:
|
||||
args = json.loads(tc.function.arguments or "{}")
|
||||
result = await self._call_jina_tool(tc.function.name, args)
|
||||
logger.info("Jina tool %s → %d chars", tc.function.name, len(result))
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": result,
|
||||
})
|
||||
|
||||
# Fallback: ask for a final answer without tools
|
||||
messages.append({"role": "user", "content": "Please provide your final answer now."})
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=max_tokens,
|
||||
model=self.model, messages=messages, max_tokens=max_tokens,
|
||||
extra_headers=extra_headers or None,
|
||||
)
|
||||
self._record_openai_usage(response)
|
||||
|
||||
Reference in New Issue
Block a user