Skip to content

Commit 9b790bd

Browse files
committed
add tests.
1 parent 0b4cb8b commit 9b790bd

19 files changed

Lines changed: 2928 additions & 151 deletions

docs/docs_en/examples/other/create_flow_image_demo.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This example builds an interactive flowchart assistant that generates and displa
1313

1414
- Environment variables: `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL_NAME` (note: this example uses OpenAI-style variable names, not the default `DEFAULT_LLM_*` names)
1515
- Python packages listed in `requirements.txt`
16-
- The `oxygent.chart` module (provides `flow_image_gen_tools`, `open_chart_tools`, `create_static_files`, and `flowchart_api`)
16+
- The `function_hubs.chart` module (provides `flow_image_gen_tools`, `open_chart_tools`, `create_static_files`, and `flowchart_api`)
1717
- A web browser for viewing generated flowcharts
1818

1919
## How to Run
@@ -58,8 +58,8 @@ The `MASTER_AGENT_PROMPT` is a detailed Chinese-language system prompt that inst
5858
| Component | Type | Key Parameters |
5959
|-----------|------|----------------|
6060
| `default_llm` | `HttpLLM` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL_NAME` from env vars |
61-
| `flow_image_gen_tools` | FunctionHub | Imported from `oxygent.chart.flow_image_gen_tools`; generates Mermaid flowcharts |
62-
| `open_chart_tools` | FunctionHub | Imported from `oxygent.chart.open_chart_tools`; opens HTML files in browser |
61+
| `flow_image_gen_tools` | FunctionHub | Imported from `function_hubs.chart.flow_image_gen_tools`; generates Mermaid flowcharts |
62+
| `open_chart_tools` | FunctionHub | Imported from `function_hubs.chart.open_chart_tools`; opens HTML files in browser |
6363
| `image_gen_agent` | `ReActAgent` | `tools=["flow_image_gen_tools"]`, desc: flowchart generation agent |
6464
| `open_chart_agent` | `ReActAgent` | `tools=["open_chart_tools"]`, desc: open flowchart in browser |
6565
| `master_agent` | `ReActAgent` | `is_master=True`, `sub_agents=["image_gen_agent", "open_chart_agent"]`, `prompt_template=MASTER_AGENT_PROMPT`, also has direct tool access to both tool sets |
@@ -69,19 +69,19 @@ The `MASTER_AGENT_PROMPT` is a detailed Chinese-language system prompt that inst
6969
The script creates a custom FastAPI application alongside the OxyGent MAS:
7070

7171
1. **CORS middleware** -- Allows all origins for development convenience.
72-
2. **Flowchart API router** -- Mounted at `/api` from `oxygent.chart.flowchart_api`.
72+
2. **Flowchart API router** -- Mounted at `/api` from `function_hubs.chart.flowchart_api`.
7373
3. **Root redirect** -- `GET /` redirects to `/static/index.html`.
74-
4. **Static files** -- The web UI is served from `oxygent/chart/web/` at the `/static` path.
74+
4. **Static files** -- The web UI is served from `function_hubs/chart/web/` at the `/static` path.
7575

7676
### Entry Point
7777

7878
```python
7979
async def main():
80-
os.makedirs("../../oxygent/chart/web", exist_ok=True)
81-
os.makedirs("../../oxygent/chart/web/css", exist_ok=True)
82-
os.makedirs("../../oxygent/chart/web/js", exist_ok=True)
83-
create_static_files("../../oxygent/chart")
84-
app.mount("/static", StaticFiles(directory="../../oxygent/chart/web"), name="web")
80+
os.makedirs("../../function_hubs/chart/web", exist_ok=True)
81+
os.makedirs("../../function_hubs/chart/web/css", exist_ok=True)
82+
os.makedirs("../../function_hubs/chart/web/js", exist_ok=True)
83+
create_static_files("../../function_hubs/chart")
84+
app.mount("/static", StaticFiles(directory="../../function_hubs/chart/web"), name="web")
8585

8686
async with MAS(oxy_space=oxy_space) as mas:
8787
await mas.start_web_service(

docs/docs_zh/examples/other/create_flow_image_demo.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
- 环境变量:`OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL_NAME`(注意:本示例使用 OpenAI 风格的变量名,而非默认的 `DEFAULT_LLM_*` 名称)
1515
- `requirements.txt` 中列出的 Python 依赖包
16-
- `oxygent.chart` 模块(提供 `flow_image_gen_tools``open_chart_tools``create_static_files``flowchart_api`
16+
- `function_hubs.chart` 模块(提供 `flow_image_gen_tools``open_chart_tools``create_static_files``flowchart_api`
1717
- Web 浏览器,用于查看生成的流程图
1818

1919
## 运行方式
@@ -58,8 +58,8 @@ Config.set_agent_llm_model("default_llm")
5858
| 组件 | 类型 | 关键参数 |
5959
|------|------|----------|
6060
| `default_llm` | `HttpLLM` | 来自环境变量的 `OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL_NAME` |
61-
| `flow_image_gen_tools` | FunctionHub |`oxygent.chart.flow_image_gen_tools` 导入;生成 Mermaid 流程图 |
62-
| `open_chart_tools` | FunctionHub |`oxygent.chart.open_chart_tools` 导入;在浏览器中打开 HTML 文件 |
61+
| `flow_image_gen_tools` | FunctionHub |`function_hubs.chart.flow_image_gen_tools` 导入;生成 Mermaid 流程图 |
62+
| `open_chart_tools` | FunctionHub |`function_hubs.chart.open_chart_tools` 导入;在浏览器中打开 HTML 文件 |
6363
| `image_gen_agent` | `ReActAgent` | `tools=["flow_image_gen_tools"]`,描述:流程图生成代理 |
6464
| `open_chart_agent` | `ReActAgent` | `tools=["open_chart_tools"]`,描述:在浏览器中打开流程图 |
6565
| `master_agent` | `ReActAgent` | `is_master=True``sub_agents=["image_gen_agent", "open_chart_agent"]``prompt_template=MASTER_AGENT_PROMPT`,同时拥有两个工具集的直接访问权限 |
@@ -69,19 +69,19 @@ Config.set_agent_llm_model("default_llm")
6969
脚本在 OxyGent MAS 之外创建了自定义 FastAPI 应用:
7070

7171
1. **CORS 中间件** -- 允许所有来源,方便开发调试。
72-
2. **流程图 API 路由** -- 从 `oxygent.chart.flowchart_api` 挂载到 `/api` 路径。
72+
2. **流程图 API 路由** -- 从 `function_hubs.chart.flowchart_api` 挂载到 `/api` 路径。
7373
3. **根路径重定向** -- `GET /` 重定向到 `/static/index.html`
74-
4. **静态文件服务** -- Web UI 从 `oxygent/chart/web/` 目录提供服务,挂载到 `/static` 路径。
74+
4. **静态文件服务** -- Web UI 从 `function_hubs/chart/web/` 目录提供服务,挂载到 `/static` 路径。
7575

7676
### 入口函数
7777

7878
```python
7979
async def main():
80-
os.makedirs("../../oxygent/chart/web", exist_ok=True)
81-
os.makedirs("../../oxygent/chart/web/css", exist_ok=True)
82-
os.makedirs("../../oxygent/chart/web/js", exist_ok=True)
83-
create_static_files("../../oxygent/chart")
84-
app.mount("/static", StaticFiles(directory="../../oxygent/chart/web"), name="web")
80+
os.makedirs("../../function_hubs/chart/web", exist_ok=True)
81+
os.makedirs("../../function_hubs/chart/web/css", exist_ok=True)
82+
os.makedirs("../../function_hubs/chart/web/js", exist_ok=True)
83+
create_static_files("../../function_hubs/chart")
84+
app.mount("/static", StaticFiles(directory="../../function_hubs/chart/web"), name="web")
8585

8686
async with MAS(oxy_space=oxy_space) as mas:
8787
await mas.start_web_service(

examples/other/create_flow_image_demo.py

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,16 @@
11
import asyncio
22
import os
3-
import sys
43

5-
# 将项目根目录添加到 Python 的模块搜索路径中
6-
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
7-
sys.path.insert(0, project_root)
8-
9-
# 加载环境变量
10-
try:
11-
from dotenv import load_dotenv
12-
13-
load_dotenv()
14-
except ImportError:
15-
print(
16-
"Warning: python-dotenv not installed. Environment variables should be set manually."
17-
)
18-
pass
19-
20-
# 导入必要的模块
21-
from oxygent import MAS, Config, oxy
22-
23-
# 导入自定义模块
24-
sys.path.append(project_root)
25-
# FastAPI 相关导入
264
from fastapi import FastAPI
275
from fastapi.middleware.cors import CORSMiddleware
286
from fastapi.staticfiles import StaticFiles
297

30-
from oxygent.chart.flow_image_gen_tools import flow_image_gen_tools
31-
from oxygent.chart.open_chart_tools import open_chart_tools
32-
from oxygent.chart.static_files_utils import create_static_files
8+
from function_hubs.chart.flow_image_gen_tools import flow_image_gen_tools
9+
from function_hubs.chart.open_chart_tools import open_chart_tools
10+
from function_hubs.chart.static_files_utils import create_static_files
11+
12+
# 导入必要的模块
13+
from oxygent import MAS, Config, oxy
3314

3415
Config.set_agent_llm_model("default_llm")
3516
# Config.set_server_auto_open_webpage(False) # 禁用自动打开浏览器
@@ -142,6 +123,7 @@
142123
api_key=os.getenv("OPENAI_API_KEY"),
143124
base_url=os.getenv("OPENAI_BASE_URL"),
144125
model_name=os.getenv("OPENAI_MODEL_NAME"),
126+
llm_params={"stream": False},
145127
),
146128
flow_image_gen_tools,
147129
open_chart_tools,
@@ -183,7 +165,7 @@
183165
)
184166

185167
# 添加API路由
186-
from oxygent.chart.flowchart_api import router as flowchart_router
168+
from function_hubs.chart.flowchart_api import router as flowchart_router
187169

188170
app.include_router(flowchart_router, prefix="/api")
189171

@@ -198,16 +180,18 @@ async def read_root():
198180

199181
async def main():
200182
# 创建web目录(如果不存在)
201-
os.makedirs("../../oxygent/chart/web", exist_ok=True)
202-
os.makedirs("../../oxygent/chart/web/css", exist_ok=True)
203-
os.makedirs("../../oxygent/chart/web/js", exist_ok=True)
183+
os.makedirs("../../function_hubs/chart/web", exist_ok=True)
184+
os.makedirs("../../function_hubs/chart/web/css", exist_ok=True)
185+
os.makedirs("../../function_hubs/chart/web/js", exist_ok=True)
204186

205187
# 创建静态文件
206-
create_static_files("../../oxygent/chart")
188+
create_static_files("../../function_hubs/chart")
207189

208190
# 使用MAS启动Web服务
209191
# 注意:静态文件挂载必须在API路由之后,否则会覆盖API路由
210-
app.mount("/static", StaticFiles(directory="../../oxygent/chart/web"), name="web")
192+
app.mount(
193+
"/static", StaticFiles(directory="../../function_hubs/chart/web"), name="web"
194+
)
211195

212196
async with MAS(oxy_space=oxy_space) as mas:
213197
# 启动Web服务,但不使用默认的 first_query 处理方式

function_hubs/chart/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
tool_modules = [
88
"flow_image_gen_tools",
99
"open_chart_tools",
10-
"flowchart_api"
1110
]
1211

1312
__all__ = []

function_hubs/chart/flowchart_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async def save_flowchart(request: SaveFlowchartRequest):
3535
file_name = f"flowchart-{timestamp}.html"
3636

3737
# 创建HTML内容
38-
from oxygent.chart.create_flow_image import create_html_with_mermaid
38+
from function_hubs.chart.flow_image_gen_tools import create_html_with_mermaid
3939

4040
success = create_html_with_mermaid(request.mermaid_code, file_name)
4141

@@ -51,7 +51,7 @@ async def generate_flowchart(request: GenerateFlowchartRequest):
5151
"""根据描述生成流程图"""
5252
try:
5353
# 导入生成函数
54-
from oxygent.chart.create_flow_image import generate_flow_chart, call_ollama_api, create_html_with_mermaid
54+
from function_hubs.chart.flow_image_gen_tools import create_html_with_mermaid, call_openai_api
5555

5656
# 生成文件名
5757
if request.file_name:
@@ -65,8 +65,8 @@ async def generate_flowchart(request: GenerateFlowchartRequest):
6565
# 确保使用绝对路径
6666
file_name = os.path.abspath(file_name)
6767

68-
# 调用 Ollama API 生成 Mermaid 代码
69-
mermaid_code = call_ollama_api(request.description)
68+
# 调用 OpenAI 兼容 API 生成 Mermaid 代码
69+
mermaid_code = call_openai_api(request.description)
7070

7171
# 创建 HTML 文件
7272
success = create_html_with_mermaid(mermaid_code, file_name)
@@ -75,7 +75,7 @@ async def generate_flowchart(request: GenerateFlowchartRequest):
7575
abs_path = os.path.abspath(file_name)
7676

7777
# 每次请求都打开浏览器
78-
from oxygent.chart.open_chart_tools import open_chart_tools
78+
from function_hubs.chart.open_chart_tools import open_chart_tools
7979
print(f"准备打开文件: {abs_path}")
8080
await open_chart_tools.open_html_chart(abs_path)
8181

oxygent/oxy/mcp_tools/base_mcp_client.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,22 +167,44 @@ async def cleanup(self) -> None:
167167
Safely closes the MCP server session and all associated resources. Uses a
168168
cleanup lock to prevent concurrent cleanup operations and handles cancellation
169169
and other exceptions gracefully.
170+
171+
When ``init()`` ran inside ``asyncio.gather`` (which creates a child
172+
task), the anyio cancel-scope was entered in that child task. Closing
173+
the exit stack from the current (different) task raises a
174+
``RuntimeError``. In that case we fall back to
175+
``_on_cross_task_cleanup`` so subclasses can do transport-specific
176+
teardown (e.g. killing a subprocess).
170177
"""
171178
async with self._cleanup_lock:
172179
try:
173180
await self._exit_stack.aclose()
181+
except RuntimeError as e:
182+
if "cancel scope" in str(e) or "different task" in str(e):
183+
await self._on_cross_task_cleanup()
184+
else:
185+
logger.warning(
186+
f"MCP client cleanup failed for server '{self.name}': {e}",
187+
exc_info=True,
188+
)
174189
except asyncio.CancelledError:
175-
# TODO cleanup(): Operation was cancelled
176190
logger.error(
177191
f"MCP client cleanup was cancelled for server '{self.name}'",
178192
exc_info=True,
179193
)
180194
except Exception as e:
181-
# Suppress cleanup exceptions to prevent cascading failures
182195
logger.warning(
183196
f"MCP client cleanup failed for server '{self.name}': {e}",
184197
exc_info=True,
185198
)
186199
finally:
187200
self._session = None
188201
self._stdio_context = None
202+
203+
async def _on_cross_task_cleanup(self) -> None:
204+
"""Hook for subclasses to handle cleanup when the exit stack cannot
205+
be closed because it was entered in a different asyncio task.
206+
207+
The default implementation is a no-op (suitable for HTTP-based
208+
transports where the connection simply goes away). ``StdioMCPClient``
209+
overrides this to terminate the child process.
210+
"""

oxygent/oxy/mcp_tools/stdio_mcp_client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
through stdin/stdout pipes.
77
"""
88

9+
import asyncio
910
import logging
1011
import os
1112
import shutil
13+
import signal
1214
from typing import Any, Optional
1315

1416
from mcp import ClientSession, StdioServerParameters
@@ -19,6 +21,8 @@
1921

2022
logger = logging.getLogger(__name__)
2123

24+
_TERMINATE_TIMEOUT = 2.0
25+
2226

2327
class StdioMCPClient(BaseMCPClient):
2428
"""MCP client implementation using standard I/O transport.
@@ -87,6 +91,48 @@ async def init(self, is_fetch_tools: bool = True) -> None:
8791
await self.cleanup()
8892
raise Exception(f"Server {self.name} error")
8993

94+
async def _on_cross_task_cleanup(self) -> None:
95+
"""Terminate the MCP subprocess directly via signals.
96+
97+
Used as a fallback when the exit-stack teardown cannot run in the
98+
original task.
99+
"""
100+
process = self._find_stdio_process()
101+
if process is None:
102+
return
103+
104+
pid = process.pid
105+
try:
106+
os.killpg(os.getpgid(pid), signal.SIGTERM)
107+
except (ProcessLookupError, PermissionError, OSError):
108+
pass
109+
110+
try:
111+
await asyncio.wait_for(process.wait(), timeout=_TERMINATE_TIMEOUT)
112+
except asyncio.TimeoutError:
113+
try:
114+
os.killpg(os.getpgid(pid), signal.SIGKILL)
115+
except (ProcessLookupError, PermissionError, OSError):
116+
pass
117+
except ProcessLookupError:
118+
pass
119+
120+
def _find_stdio_process(self) -> Any:
121+
"""Walk the exit-stack callbacks to locate the anyio Process handle."""
122+
for cb in reversed(self._exit_stack._exit_callbacks):
123+
wrapper = getattr(cb[1], "__self__", None)
124+
if wrapper is None:
125+
continue
126+
gen = getattr(wrapper, "gen", None)
127+
if gen is None:
128+
continue
129+
frame = getattr(gen, "ag_frame", None) or getattr(gen, "gi_frame", None)
130+
if frame and "process" in frame.f_locals:
131+
proc = frame.f_locals["process"]
132+
if hasattr(proc, "pid"):
133+
return proc
134+
return None
135+
90136
async def call_tool(self, tool_name: str, arguments: dict[str, Any], headers: Optional[dict[str, str]] = None) -> Any:
91137
server_params = await self.get_server_params()
92138
async with stdio_client(server_params) as streams:

0 commit comments

Comments
 (0)