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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user