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
+102 -2
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,14 +471,109 @@ 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()
response = await self.client.chat.completions.create(
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=[{"role": "user", "content": prompt}],
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=messages, max_tokens=max_tokens,
extra_headers=extra_headers or None,
)
self._record_openai_usage(response)
return response.choices[0].message.content or ""
+13
View File
@@ -87,6 +87,19 @@ spec:
name: swarm-model-key
key: OPENAI_API_KEY
optional: true
# Sandbox: default in-pod code-execution quality evaluation, fail-closed double gate
# (ENABLE_QUALITY_EVAL + HEICODE_SANDBOX_ISOLATED). Matches the deployed environment.
- name: ENABLE_QUALITY_EVAL
value: "1"
- name: HEICODE_SANDBOX_ISOLATED
value: "1"
# Jina MCP key — passed through to launched agent pods via agent_launcher
- name: JINA_API_KEY
valueFrom:
secretKeyRef:
name: swarm-jina-key
key: JINA_API_KEY
optional: true
resources:
requests: { memory: "256Mi", cpu: "200m" }
limits: { memory: "512Mi", cpu: "500m" }
+6 -1
View File
@@ -490,6 +490,11 @@ def plan_launch_specs(
env["OPENAI_MODEL"] = mid
if user_id:
env["HEICODE_USER_ID"] = user_id
# Jina MCP key — passed through from orchestrator env so launched agents can call Jina
# search/read tools. Secret-classed (SENSITIVE_ENV_KEYS) → delivered via per-swarm Secret.
jina_key = os.getenv("JINA_API_KEY")
if jina_key:
env["JINA_API_KEY"] = jina_key
if git_env:
env.update(git_env)
# LOCAL TEST ONLY (not for upstream): propagate the agent-side subtask-handoff switch so
@@ -585,7 +590,7 @@ _k8s_swarms: set = set()
#: Env keys that carry secrets — delivered via the per-swarm k8s Secret (`secretKeyRef`), NEVER
#: inlined into the PodSpec (would leak into etcd / `kubectl get pod -o yaml`). `GIT_USERNAME` and
#: `GIT_REPO_URL` are non-secret and stay inline.
SENSITIVE_ENV_KEYS = ("OPENAI_API_KEY", "GIT_PASSWORD")
SENSITIVE_ENV_KEYS = ("OPENAI_API_KEY", "GIT_PASSWORD", "JINA_API_KEY")
def pod_image() -> str: