94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
287 lines
9.9 KiB
Python
287 lines
9.9 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import orjson
|
|
|
|
from sglang.srt.entrypoints.search.exa_client import (
|
|
ExaClient,
|
|
ExaSearchConfig,
|
|
)
|
|
from sglang.srt.environ import envs
|
|
from sglang.srt.utils import print_info_once, print_warning_once
|
|
|
|
if TYPE_CHECKING:
|
|
# Avoid circular import.
|
|
from sglang.srt.entrypoints.context import ConversationContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Tool(ABC):
|
|
|
|
@abstractmethod
|
|
async def get_result(self, context: "ConversationContext") -> Any:
|
|
pass
|
|
|
|
|
|
class HarmonyBrowserTool(Tool):
|
|
|
|
def __init__(self, client: ExaClient | None = None):
|
|
self.enabled = True
|
|
if client is not None:
|
|
self.exa_client = client
|
|
print_info_once("Browser tool initialized")
|
|
return
|
|
|
|
api_key = envs.EXA_API_KEY.get()
|
|
if not api_key:
|
|
self.enabled = False
|
|
print_warning_once("EXA_API_KEY is not set, browsing is disabled")
|
|
return
|
|
|
|
self.exa_client = ExaClient(api_key, config=ExaSearchConfig.from_env())
|
|
print_info_once("Browser tool initialized")
|
|
|
|
async def get_result(self, context: "ConversationContext") -> Any:
|
|
from openai_harmony import Author, Message, Role, TextContent
|
|
|
|
last_msg = context.messages[-1]
|
|
recipient = last_msg.recipient
|
|
if recipient is None or not recipient.startswith("browser."):
|
|
raise ValueError("No browser tool call found")
|
|
|
|
try:
|
|
args = orjson.loads(last_msg.content[0].text)
|
|
result_text = await self._dispatch_browser_call(context, recipient, args)
|
|
except Exception as exc:
|
|
logger.exception("Browser tool call failed")
|
|
result_text = f"Browser tool call failed: {exc}"
|
|
|
|
content = TextContent(text=result_text)
|
|
author = Author(role=Role.TOOL, name=recipient)
|
|
return [Message(author=author, content=[content], recipient=Role.ASSISTANT)]
|
|
|
|
async def _dispatch_browser_call(
|
|
self, context: "ConversationContext", recipient: str, args: dict[str, Any]
|
|
) -> str:
|
|
if recipient == "browser.search":
|
|
query = args.get("query")
|
|
if not query:
|
|
raise ValueError("browser.search requires a query")
|
|
data = await self.exa_client.search(query)
|
|
return self._format_search_results(context, query, data)
|
|
|
|
if recipient == "browser.open":
|
|
url = self._resolve_url(context, args)
|
|
data = await self.exa_client.contents([url])
|
|
return self._format_page_contents(context, url, data)
|
|
|
|
if recipient == "browser.find":
|
|
pattern = args.get("pattern")
|
|
if not pattern:
|
|
raise ValueError("browser.find requires a pattern")
|
|
return await self._find_pattern(context, args, pattern)
|
|
|
|
raise ValueError(f"Unknown browser action: {recipient}")
|
|
|
|
def _browser_state(self, context: "ConversationContext") -> dict[str, Any]:
|
|
state = getattr(context, "_sglang_exa_browser_state", None)
|
|
if state is None:
|
|
state = {"pages": {}, "page_text": {}}
|
|
setattr(context, "_sglang_exa_browser_state", state)
|
|
return state
|
|
|
|
def _format_search_results(
|
|
self, context: "ConversationContext", query: str, data: dict[str, Any]
|
|
) -> str:
|
|
state = self._browser_state(context)
|
|
state["pages"] = {}
|
|
state["page_text"] = {}
|
|
|
|
results = data.get("results") or []
|
|
request_id = data.get("requestId")
|
|
if request_id:
|
|
logger.debug("Exa search request id: %s", request_id)
|
|
lines = [f"Search results for: {query}"]
|
|
lines.append("Use browser.open with the cursor number to inspect a result.")
|
|
|
|
for index, result in enumerate(results, start=1):
|
|
cursor = str(index)
|
|
state["pages"][cursor] = result
|
|
title = result.get("title") or "Untitled"
|
|
url = result.get("url") or result.get("id") or ""
|
|
snippet = self._best_snippet(result)
|
|
lines.append("")
|
|
lines.append(f"[{cursor}] {title}")
|
|
if url:
|
|
lines.append(f"URL: {url}")
|
|
if snippet:
|
|
lines.append(f"Snippet: {snippet}")
|
|
|
|
if not results:
|
|
lines.append("No results found.")
|
|
return "\n".join(lines)
|
|
|
|
def _format_page_contents(
|
|
self, context: "ConversationContext", url: str, data: dict[str, Any]
|
|
) -> str:
|
|
state = self._browser_state(context)
|
|
results = data.get("results") or []
|
|
if not results:
|
|
return f"No page contents returned for {url}."
|
|
|
|
page = results[0]
|
|
cursor = self._cursor_for_url(state, url)
|
|
if cursor:
|
|
state["pages"][cursor] = page
|
|
title = page.get("title") or "Untitled"
|
|
page_url = page.get("url") or url
|
|
text = page.get("text") or self._best_snippet(page) or page.get("summary") or ""
|
|
if cursor:
|
|
state["page_text"][cursor] = text
|
|
|
|
return "\n".join(
|
|
[
|
|
f"Opened page: {title}",
|
|
f"URL: {page_url}",
|
|
"",
|
|
self._truncate(text, 12000) if text else "No page text available.",
|
|
]
|
|
)
|
|
|
|
async def _find_pattern(
|
|
self, context: "ConversationContext", args: dict[str, Any], pattern: str
|
|
) -> str:
|
|
state = self._browser_state(context)
|
|
cursor = self._normalize_cursor(args.get("cursor"))
|
|
|
|
if cursor:
|
|
text = state["page_text"].get(cursor)
|
|
if text is None:
|
|
url = self._resolve_url(context, args)
|
|
data = await self.exa_client.contents([url])
|
|
self._format_page_contents(context, url, data)
|
|
text = state["page_text"].get(cursor, "")
|
|
return self._format_matches(pattern, text)
|
|
|
|
if args.get("url"):
|
|
url = self._resolve_url(context, args)
|
|
data = await self.exa_client.contents([url])
|
|
results = data.get("results") or []
|
|
if not results:
|
|
return f"No page contents returned for {url}."
|
|
page = results[0]
|
|
text = (
|
|
page.get("text")
|
|
or self._best_snippet(page)
|
|
or page.get("summary")
|
|
or ""
|
|
)
|
|
return self._format_matches(pattern, text)
|
|
|
|
searchable_text = "\n\n".join(
|
|
self._best_snippet(page) for page in state["pages"].values()
|
|
)
|
|
return self._format_matches(pattern, searchable_text)
|
|
|
|
def _resolve_url(self, context: "ConversationContext", args: dict[str, Any]) -> str:
|
|
if args.get("url"):
|
|
return str(args["url"])
|
|
|
|
state = self._browser_state(context)
|
|
cursor = self._normalize_cursor(args.get("cursor"))
|
|
if not cursor:
|
|
raise ValueError("browser.open requires a cursor or url")
|
|
|
|
page = state["pages"].get(cursor)
|
|
if page is None:
|
|
raise ValueError(f"Unknown browser cursor: {cursor}")
|
|
|
|
url = page.get("url") or page.get("id")
|
|
if not url:
|
|
raise ValueError(f"No URL recorded for browser cursor: {cursor}")
|
|
return str(url)
|
|
|
|
def _normalize_cursor(self, cursor: Any) -> str | None:
|
|
if cursor is None:
|
|
return None
|
|
cursor_str = str(cursor)
|
|
# GPT-OSS emits 0 as a 1-based cursor; map it to the first result.
|
|
if cursor_str == "0":
|
|
return "1"
|
|
return cursor_str
|
|
|
|
def _cursor_for_url(self, state: dict[str, Any], url: str) -> str | None:
|
|
for cursor, page in state["pages"].items():
|
|
if page.get("url") == url or page.get("id") == url:
|
|
return cursor
|
|
return None
|
|
|
|
def _best_snippet(self, result: dict[str, Any]) -> str:
|
|
highlights = result.get("highlights") or []
|
|
if highlights:
|
|
return self._truncate(str(highlights[0]), 1000)
|
|
summary = result.get("summary")
|
|
if summary:
|
|
return self._truncate(str(summary), 1000)
|
|
text = result.get("text")
|
|
if text:
|
|
return self._truncate(str(text), 1000)
|
|
return ""
|
|
|
|
def _format_matches(self, pattern: str, text: str) -> str:
|
|
if not text:
|
|
return f"No text available to search for {pattern!r}."
|
|
|
|
pattern_lower = pattern.lower()
|
|
matches = []
|
|
for line in text.splitlines():
|
|
if pattern_lower in line.lower():
|
|
matches.append(self._truncate(line.strip(), 1000))
|
|
if len(matches) >= 10:
|
|
break
|
|
|
|
if not matches:
|
|
return f"No matches found for {pattern!r}."
|
|
|
|
lines = [f"Matches for {pattern!r}:"]
|
|
lines.extend(f"- {match}" for match in matches)
|
|
return "\n".join(lines)
|
|
|
|
def _truncate(self, text: str, max_chars: int) -> str:
|
|
if len(text) <= max_chars:
|
|
return text
|
|
return text[: max_chars - 3] + "..."
|
|
|
|
|
|
class HarmonyPythonTool(Tool):
|
|
|
|
def __init__(self):
|
|
self.enabled = True
|
|
|
|
try:
|
|
from gpt_oss.tools.python_docker.docker_tool import PythonTool
|
|
except ImportError:
|
|
self.enabled = False
|
|
print_warning_once("gpt_oss is not installed, code interpreter is disabled")
|
|
return
|
|
|
|
self.python_tool = PythonTool()
|
|
print_info_once("Code interpreter tool initialized")
|
|
|
|
async def get_result(self, context: "ConversationContext") -> Any:
|
|
from sglang.srt.entrypoints.context import HarmonyContext
|
|
|
|
assert isinstance(context, HarmonyContext)
|
|
last_msg = context.messages[-1]
|
|
tool_output_msgs = []
|
|
async for msg in self.python_tool.process(last_msg):
|
|
tool_output_msgs.append(msg)
|
|
return tool_output_msgs
|
|
|
|
@property
|
|
def tool_config(self) -> Any:
|
|
return self.python_tool.tool_config
|