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:
gongzhiyong
2026-06-21 20:45:56 +08:00
parent 167adfcb56
commit e263dae115
4 changed files with 126 additions and 5 deletions
+4 -1
View File
@@ -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
View File
@@ -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)