tina-python 0.6.4__tar.gz → 0.6.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tina_python-0.6.4/tina_python.egg-info → tina_python-0.6.6}/PKG-INFO +29 -15
- {tina_python-0.6.4 → tina_python-0.6.6}/README.md +24 -13
- {tina_python-0.6.4 → tina_python-0.6.6}/pyproject.toml +6 -2
- {tina_python-0.6.4 → tina_python-0.6.6}/setup.py +1 -1
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/__init__.py +1 -1
- tina_python-0.6.6/tina/mcp/client.py +364 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/mcp/mcp_tools_executor.py +11 -2
- {tina_python-0.6.4 → tina_python-0.6.6/tina_python.egg-info}/PKG-INFO +29 -15
- {tina_python-0.6.4 → tina_python-0.6.6}/tina_python.egg-info/requires.txt +5 -1
- tina_python-0.6.4/tina/mcp/client.py +0 -239
- {tina_python-0.6.4 → tina_python-0.6.6}/LICENSE +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/MANIFEST.in +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/setup.cfg +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/__init__.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/agent.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/__init__.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/agent_response.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/agent_runtime.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/context_manager.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/events.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/executor.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/keyword_actions.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/prompt.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/state.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/core/tools.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/agent/multimodal_agent.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/core/__init__.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/core/error.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/core/logger.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/llm/BaseNewAPI.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/llm/__init__.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/llm/base_api.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/llm/base_multimodal_api.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/llm/ollama_api.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/mcp/__init__.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/py.typed +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/__init__.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/agent_worker.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/deepseek.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/doc_parser.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/env_reader.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/multimodal_formatter.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/output_parser.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/run_agent_in_cli.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/system_tools.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/timer.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/tui/__init__.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/tui/context_manager.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/tui/token.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/tui/tui.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina/utils/type_mapper.py +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina_python.egg-info/SOURCES.txt +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina_python.egg-info/dependency_links.txt +0 -0
- {tina_python-0.6.4 → tina_python-0.6.6}/tina_python.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tina-python
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.6
|
|
4
4
|
Summary: tina is in your computer!
|
|
5
5
|
Home-page: https://gitee.com/wang-churi/tina
|
|
6
6
|
Author: 王出日
|
|
@@ -20,11 +20,14 @@ Requires-Dist: python-dotenv
|
|
|
20
20
|
Provides-Extra: tui
|
|
21
21
|
Requires-Dist: textual>=0.80; extra == "tui"
|
|
22
22
|
Provides-Extra: mcp
|
|
23
|
-
Requires-Dist: mcp
|
|
23
|
+
Requires-Dist: mcp; extra == "mcp"
|
|
24
24
|
Provides-Extra: test
|
|
25
25
|
Requires-Dist: pytest>=9.0; extra == "test"
|
|
26
26
|
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
27
27
|
Requires-Dist: pytest-cov>=7.0; extra == "test"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: textual>=0.80; extra == "all"
|
|
30
|
+
Requires-Dist: mcp; extra == "all"
|
|
28
31
|
Dynamic: author
|
|
29
32
|
Dynamic: home-page
|
|
30
33
|
Dynamic: license-file
|
|
@@ -47,6 +50,12 @@ pip install tina-python
|
|
|
47
50
|
# 如果需要使用 MCP(模型上下文协议)服务
|
|
48
51
|
pip install tina-python[mcp]
|
|
49
52
|
|
|
53
|
+
# 如果需要 TUI 终端界面
|
|
54
|
+
pip install tina-python[tui]
|
|
55
|
+
|
|
56
|
+
# 一次装齐所有可选功能(TUI + MCP)
|
|
57
|
+
pip install tina-python[all]
|
|
58
|
+
|
|
50
59
|
# 如果需要运行测试
|
|
51
60
|
pip install tina-python[test]
|
|
52
61
|
```
|
|
@@ -208,8 +217,9 @@ result = await llm.apredict(
|
|
|
208
217
|
stream=False
|
|
209
218
|
)
|
|
210
219
|
|
|
211
|
-
#
|
|
212
|
-
|
|
220
|
+
# 异步流式:先 await 拿到异步生成器
|
|
221
|
+
result = await llm.apredict(input_text="讲个故事", stream=True)
|
|
222
|
+
async for chunk in result:
|
|
213
223
|
print(chunk["content"], end="")
|
|
214
224
|
```
|
|
215
225
|
|
|
@@ -239,8 +249,6 @@ async for chunk in llm.apredict(input_text="讲个故事", stream=True):
|
|
|
239
249
|
{
|
|
240
250
|
"role": "assistant",
|
|
241
251
|
"content": "回复内容",
|
|
242
|
-
# 推理模型时会出现
|
|
243
|
-
"reasoning_content": "推理链...",
|
|
244
252
|
# 有工具调用时会出现
|
|
245
253
|
"tool_calls": [
|
|
246
254
|
{
|
|
@@ -252,7 +260,8 @@ async for chunk in llm.apredict(input_text="讲个故事", stream=True):
|
|
|
252
260
|
]
|
|
253
261
|
}
|
|
254
262
|
|
|
255
|
-
#
|
|
263
|
+
# 流式:逐块返回片段,工具调用以 tool_name / tool_arguments 片段给出;
|
|
264
|
+
# 推理模型的思维链在 reasoning_content 中,末尾可能带有 usage
|
|
256
265
|
```
|
|
257
266
|
|
|
258
267
|
### 1.8 获取可用模型列表
|
|
@@ -389,9 +398,12 @@ tool_calls = [
|
|
|
389
398
|
}
|
|
390
399
|
}
|
|
391
400
|
]
|
|
392
|
-
|
|
401
|
+
# execute 需要事件对象(Agent 内部会自动传入)
|
|
402
|
+
from tina import AgentEvents
|
|
403
|
+
|
|
404
|
+
results = tools.execute(tool_calls, events=AgentEvents())
|
|
393
405
|
# [
|
|
394
|
-
# {"role": "tool", "tool_call_id": "call_123", "
|
|
406
|
+
# {"role": "tool", "tool_call_id": "call_123", "tool_name": "get_weather", "content": "北京今天天气晴朗"}
|
|
395
407
|
# ]
|
|
396
408
|
```
|
|
397
409
|
|
|
@@ -539,7 +551,7 @@ elif agent.state == AgentState.ERROR:
|
|
|
539
551
|
# 获取消息列表
|
|
540
552
|
messages = agent.get_messages()
|
|
541
553
|
|
|
542
|
-
#
|
|
554
|
+
# 清空全部消息(含系统提示词,之后需重新 set_system_prompt)
|
|
543
555
|
agent.clear_messages()
|
|
544
556
|
|
|
545
557
|
# 获取系统提示词
|
|
@@ -627,7 +639,7 @@ def on_turn_end():
|
|
|
627
639
|
agent_a = Agent(llm=llm, tools=tools, name="agent_a")
|
|
628
640
|
agent_b = Agent(llm=llm, tools=tools, name="agent_b")
|
|
629
641
|
|
|
630
|
-
#
|
|
642
|
+
# 关联另一个 Agent(记录到 other_agents,目前不会自动共享消息列表)
|
|
631
643
|
agent_a.connect_agent(agent_b)
|
|
632
644
|
|
|
633
645
|
# 断开连接
|
|
@@ -692,6 +704,8 @@ tina 支持通过 MCP(Model Context Protocol)连接外部工具生态。
|
|
|
692
704
|
pip install tina-python[mcp]
|
|
693
705
|
```
|
|
694
706
|
|
|
707
|
+
> 想一次装齐所有可选功能(TUI + MCP):`pip install tina-python[all]`
|
|
708
|
+
|
|
695
709
|
### 4.2 使用 MCP 客户端
|
|
696
710
|
|
|
697
711
|
```python
|
|
@@ -884,8 +898,8 @@ from tina import Agent, Tools
|
|
|
884
898
|
from tina.llm import BaseAPI
|
|
885
899
|
from tina.utils.system_tools import system_tools
|
|
886
900
|
|
|
887
|
-
tools = Tools()
|
|
888
|
-
tools += system_tools # 添加 tina
|
|
901
|
+
tools = Tools(name="my_tools")
|
|
902
|
+
tools += system_tools # 添加 tina 自带的系统工具包(工具集需命名)
|
|
889
903
|
|
|
890
904
|
agent = Agent(
|
|
891
905
|
llm=BaseAPI(),
|
|
@@ -987,7 +1001,7 @@ run_agent_in_cli(agent)
|
|
|
987
1001
|
|------|------|
|
|
988
1002
|
| `#context` | 查看当前对话上下文(消息列表) |
|
|
989
1003
|
| `#clear` | 清屏 |
|
|
990
|
-
|
|
|
1004
|
+
| `exit` / `quit` | 退出对话(`#exit` 不支持,会被当作普通输入) |
|
|
991
1005
|
|
|
992
1006
|
### 6.4 多模态消息构建器 `build_multimodal_message`
|
|
993
1007
|
|
|
@@ -1077,7 +1091,7 @@ MAX_INPUT=8000
|
|
|
1077
1091
|
| 用途 | 依赖 |
|
|
1078
1092
|
|------|------|
|
|
1079
1093
|
| 核心功能 | `httpx`, `python-dotenv` |
|
|
1080
|
-
| MCP 支持 | `mcp`
|
|
1094
|
+
| MCP 支持 | `mcp` |
|
|
1081
1095
|
| TUI 界面(可选) | `textual` |
|
|
1082
1096
|
| 测试 | `pytest`, `pytest-asyncio`, `pytest-cov` |
|
|
1083
1097
|
|
|
@@ -15,6 +15,12 @@ pip install tina-python
|
|
|
15
15
|
# 如果需要使用 MCP(模型上下文协议)服务
|
|
16
16
|
pip install tina-python[mcp]
|
|
17
17
|
|
|
18
|
+
# 如果需要 TUI 终端界面
|
|
19
|
+
pip install tina-python[tui]
|
|
20
|
+
|
|
21
|
+
# 一次装齐所有可选功能(TUI + MCP)
|
|
22
|
+
pip install tina-python[all]
|
|
23
|
+
|
|
18
24
|
# 如果需要运行测试
|
|
19
25
|
pip install tina-python[test]
|
|
20
26
|
```
|
|
@@ -176,8 +182,9 @@ result = await llm.apredict(
|
|
|
176
182
|
stream=False
|
|
177
183
|
)
|
|
178
184
|
|
|
179
|
-
#
|
|
180
|
-
|
|
185
|
+
# 异步流式:先 await 拿到异步生成器
|
|
186
|
+
result = await llm.apredict(input_text="讲个故事", stream=True)
|
|
187
|
+
async for chunk in result:
|
|
181
188
|
print(chunk["content"], end="")
|
|
182
189
|
```
|
|
183
190
|
|
|
@@ -207,8 +214,6 @@ async for chunk in llm.apredict(input_text="讲个故事", stream=True):
|
|
|
207
214
|
{
|
|
208
215
|
"role": "assistant",
|
|
209
216
|
"content": "回复内容",
|
|
210
|
-
# 推理模型时会出现
|
|
211
|
-
"reasoning_content": "推理链...",
|
|
212
217
|
# 有工具调用时会出现
|
|
213
218
|
"tool_calls": [
|
|
214
219
|
{
|
|
@@ -220,7 +225,8 @@ async for chunk in llm.apredict(input_text="讲个故事", stream=True):
|
|
|
220
225
|
]
|
|
221
226
|
}
|
|
222
227
|
|
|
223
|
-
#
|
|
228
|
+
# 流式:逐块返回片段,工具调用以 tool_name / tool_arguments 片段给出;
|
|
229
|
+
# 推理模型的思维链在 reasoning_content 中,末尾可能带有 usage
|
|
224
230
|
```
|
|
225
231
|
|
|
226
232
|
### 1.8 获取可用模型列表
|
|
@@ -357,9 +363,12 @@ tool_calls = [
|
|
|
357
363
|
}
|
|
358
364
|
}
|
|
359
365
|
]
|
|
360
|
-
|
|
366
|
+
# execute 需要事件对象(Agent 内部会自动传入)
|
|
367
|
+
from tina import AgentEvents
|
|
368
|
+
|
|
369
|
+
results = tools.execute(tool_calls, events=AgentEvents())
|
|
361
370
|
# [
|
|
362
|
-
# {"role": "tool", "tool_call_id": "call_123", "
|
|
371
|
+
# {"role": "tool", "tool_call_id": "call_123", "tool_name": "get_weather", "content": "北京今天天气晴朗"}
|
|
363
372
|
# ]
|
|
364
373
|
```
|
|
365
374
|
|
|
@@ -507,7 +516,7 @@ elif agent.state == AgentState.ERROR:
|
|
|
507
516
|
# 获取消息列表
|
|
508
517
|
messages = agent.get_messages()
|
|
509
518
|
|
|
510
|
-
#
|
|
519
|
+
# 清空全部消息(含系统提示词,之后需重新 set_system_prompt)
|
|
511
520
|
agent.clear_messages()
|
|
512
521
|
|
|
513
522
|
# 获取系统提示词
|
|
@@ -595,7 +604,7 @@ def on_turn_end():
|
|
|
595
604
|
agent_a = Agent(llm=llm, tools=tools, name="agent_a")
|
|
596
605
|
agent_b = Agent(llm=llm, tools=tools, name="agent_b")
|
|
597
606
|
|
|
598
|
-
#
|
|
607
|
+
# 关联另一个 Agent(记录到 other_agents,目前不会自动共享消息列表)
|
|
599
608
|
agent_a.connect_agent(agent_b)
|
|
600
609
|
|
|
601
610
|
# 断开连接
|
|
@@ -660,6 +669,8 @@ tina 支持通过 MCP(Model Context Protocol)连接外部工具生态。
|
|
|
660
669
|
pip install tina-python[mcp]
|
|
661
670
|
```
|
|
662
671
|
|
|
672
|
+
> 想一次装齐所有可选功能(TUI + MCP):`pip install tina-python[all]`
|
|
673
|
+
|
|
663
674
|
### 4.2 使用 MCP 客户端
|
|
664
675
|
|
|
665
676
|
```python
|
|
@@ -852,8 +863,8 @@ from tina import Agent, Tools
|
|
|
852
863
|
from tina.llm import BaseAPI
|
|
853
864
|
from tina.utils.system_tools import system_tools
|
|
854
865
|
|
|
855
|
-
tools = Tools()
|
|
856
|
-
tools += system_tools # 添加 tina
|
|
866
|
+
tools = Tools(name="my_tools")
|
|
867
|
+
tools += system_tools # 添加 tina 自带的系统工具包(工具集需命名)
|
|
857
868
|
|
|
858
869
|
agent = Agent(
|
|
859
870
|
llm=BaseAPI(),
|
|
@@ -955,7 +966,7 @@ run_agent_in_cli(agent)
|
|
|
955
966
|
|------|------|
|
|
956
967
|
| `#context` | 查看当前对话上下文(消息列表) |
|
|
957
968
|
| `#clear` | 清屏 |
|
|
958
|
-
|
|
|
969
|
+
| `exit` / `quit` | 退出对话(`#exit` 不支持,会被当作普通输入) |
|
|
959
970
|
|
|
960
971
|
### 6.4 多模态消息构建器 `build_multimodal_message`
|
|
961
972
|
|
|
@@ -1045,7 +1056,7 @@ MAX_INPUT=8000
|
|
|
1045
1056
|
| 用途 | 依赖 |
|
|
1046
1057
|
|------|------|
|
|
1047
1058
|
| 核心功能 | `httpx`, `python-dotenv` |
|
|
1048
|
-
| MCP 支持 | `mcp`
|
|
1059
|
+
| MCP 支持 | `mcp` |
|
|
1049
1060
|
| TUI 界面(可选) | `textual` |
|
|
1050
1061
|
| 测试 | `pytest`, `pytest-asyncio`, `pytest-cov` |
|
|
1051
1062
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tina-python"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.6"
|
|
8
8
|
description = "tina is in your computer!"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -32,13 +32,17 @@ tui = [
|
|
|
32
32
|
"textual>=0.80",
|
|
33
33
|
]
|
|
34
34
|
mcp = [
|
|
35
|
-
"mcp
|
|
35
|
+
"mcp",
|
|
36
36
|
]
|
|
37
37
|
test = [
|
|
38
38
|
"pytest>=9.0",
|
|
39
39
|
"pytest-asyncio>=1.4",
|
|
40
40
|
"pytest-cov>=7.0",
|
|
41
41
|
]
|
|
42
|
+
all = [
|
|
43
|
+
"textual>=0.80",
|
|
44
|
+
"mcp",
|
|
45
|
+
]
|
|
42
46
|
|
|
43
47
|
[tool.pytest.ini_options]
|
|
44
48
|
testpaths = ["test"]
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import concurrent.futures
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Dict, List, Any, Optional, Union, Tuple
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from ..core import logger # 假设你有统一的logger
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from mcp import ClientSession, StdioServerParameters
|
|
13
|
+
from mcp.client.stdio import stdio_client
|
|
14
|
+
from mcp.client.sse import sse_client
|
|
15
|
+
|
|
16
|
+
MCP_AVAILABLE = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
MCP_AVAILABLE = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _tool_input_schema(tool: Any) -> Dict[str, Any]:
|
|
22
|
+
"""获取 MCP 工具的输入 schema,兼容 SDK 的字段改名
|
|
23
|
+
|
|
24
|
+
MCP Python SDK 早期版本用驼峰 `inputSchema`,新版本(pydantic 模型)改为
|
|
25
|
+
下划线 `input_schema`。这里两种都取,取不到返回空 dict。
|
|
26
|
+
"""
|
|
27
|
+
schema = getattr(tool, "input_schema", None)
|
|
28
|
+
if schema is None:
|
|
29
|
+
schema = getattr(tool, "inputSchema", None)
|
|
30
|
+
return schema or {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_property(prop: Any) -> Dict[str, Any]:
|
|
34
|
+
"""将单个 MCP 属性 schema 归一化为 Tina/大模型友好的形式。
|
|
35
|
+
|
|
36
|
+
修复原先只保留 `type` 且缺省退化为 Python 类型名 ``"str"`` 的问题:
|
|
37
|
+
- 缺少顶层 `type` 时,从 enum / anyOf 推断,最后回退为 JSON Schema 的
|
|
38
|
+
``"string"``,不再输出 ``"str"``;
|
|
39
|
+
- 保留 `enum` 等约束信息,避免模型失去取值范围;
|
|
40
|
+
- 将带 null 的 `anyOf`/`oneOf` 及 ``["string", "null"]`` 形式的 type
|
|
41
|
+
压平为 `type` + `nullable`,提升对不支持联合类型的模型兼容性。
|
|
42
|
+
"""
|
|
43
|
+
prop = dict(prop) if isinstance(prop, dict) else {}
|
|
44
|
+
description = prop.get("description", "")
|
|
45
|
+
|
|
46
|
+
# 压平 nullable 联合类型(例如 zod 的 z.enum([...]).nullable())
|
|
47
|
+
variants = prop.get("anyOf") or prop.get("oneOf")
|
|
48
|
+
if isinstance(variants, list):
|
|
49
|
+
non_null = [
|
|
50
|
+
v for v in variants if isinstance(v, dict) and v.get("type") != "null"
|
|
51
|
+
]
|
|
52
|
+
has_null = any(
|
|
53
|
+
isinstance(v, dict) and v.get("type") == "null" for v in variants
|
|
54
|
+
)
|
|
55
|
+
if len(non_null) == 1 and has_null:
|
|
56
|
+
merged = dict(non_null[0])
|
|
57
|
+
merged.setdefault("description", description)
|
|
58
|
+
merged["nullable"] = True
|
|
59
|
+
return _normalize_property(merged)
|
|
60
|
+
|
|
61
|
+
# 压平 type 为数组的形式,如 ["string", "null"]
|
|
62
|
+
raw_type = prop.get("type")
|
|
63
|
+
if isinstance(raw_type, list):
|
|
64
|
+
types = [t for t in raw_type if t != "null"]
|
|
65
|
+
prop["nullable"] = prop.get("nullable", "null" in raw_type)
|
|
66
|
+
if len(types) == 1:
|
|
67
|
+
prop["type"] = types[0]
|
|
68
|
+
else:
|
|
69
|
+
prop.pop("type", None)
|
|
70
|
+
|
|
71
|
+
# 缺少 type 时从 enum 推断,否则回退 "string"(而非 Python 的 "str")
|
|
72
|
+
if "type" not in prop:
|
|
73
|
+
values = prop.get("enum")
|
|
74
|
+
if isinstance(values, list) and values:
|
|
75
|
+
if all(isinstance(v, str) for v in values):
|
|
76
|
+
prop["type"] = "string"
|
|
77
|
+
elif all(
|
|
78
|
+
isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
79
|
+
for v in values
|
|
80
|
+
):
|
|
81
|
+
prop["type"] = "number"
|
|
82
|
+
elif not (
|
|
83
|
+
prop.get("anyOf") or prop.get("oneOf") or prop.get("$ref")
|
|
84
|
+
):
|
|
85
|
+
prop["type"] = "string"
|
|
86
|
+
|
|
87
|
+
prop["description"] = description
|
|
88
|
+
return prop
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _convert_input_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
92
|
+
"""将 MCP 工具的 input schema.properties 转为 Tina 工具参数格式"""
|
|
93
|
+
properties = schema.get("properties", {}) or {}
|
|
94
|
+
return {name: _normalize_property(prop) for name, prop in properties.items()}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class MCPClient:
|
|
98
|
+
def __init__(self):
|
|
99
|
+
if not MCP_AVAILABLE:
|
|
100
|
+
raise ImportError("请先安装MCP依赖: pip install mcp")
|
|
101
|
+
|
|
102
|
+
self.servers = (
|
|
103
|
+
{}
|
|
104
|
+
) # {server_id: {"session": s, "tools": [], "config": {}, "stop": Event}}
|
|
105
|
+
self.request_history = []
|
|
106
|
+
self._server_futures = {} # {server_id: concurrent.futures.Future}
|
|
107
|
+
self.loop = asyncio.new_event_loop()
|
|
108
|
+
self._loop_is_running = False
|
|
109
|
+
self._lock = threading.Lock() # 保护 servers 字典的线程安全
|
|
110
|
+
|
|
111
|
+
self.start_loop_thread()
|
|
112
|
+
|
|
113
|
+
def start_loop_thread(self):
|
|
114
|
+
"""启动专用后台线程运行事件循环"""
|
|
115
|
+
|
|
116
|
+
def run_event_loop():
|
|
117
|
+
asyncio.set_event_loop(self.loop)
|
|
118
|
+
self._loop_is_running = True
|
|
119
|
+
try:
|
|
120
|
+
self.loop.run_forever()
|
|
121
|
+
finally:
|
|
122
|
+
# 最后的清理逻辑
|
|
123
|
+
self._loop_is_running = False
|
|
124
|
+
self.loop.close()
|
|
125
|
+
|
|
126
|
+
thread = threading.Thread(target=run_event_loop, daemon=True)
|
|
127
|
+
thread.start()
|
|
128
|
+
|
|
129
|
+
# --- 核心内部异步实现 ---
|
|
130
|
+
|
|
131
|
+
async def _serve_server(
|
|
132
|
+
self,
|
|
133
|
+
server_id: str,
|
|
134
|
+
config: Dict[str, Any],
|
|
135
|
+
ready: concurrent.futures.Future,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""在同一个 task 内完成 MCP 会话的进入与退出。
|
|
138
|
+
|
|
139
|
+
mcp 2.x 基于 anyio,context 的进入/退出必须在同一个 task,
|
|
140
|
+
否则退出时会报 "Attempted to exit cancel scope in a different task"。
|
|
141
|
+
因此这里用一个常驻 task 持有会话,直到 stop 事件触发再统一关闭。
|
|
142
|
+
"""
|
|
143
|
+
stack = AsyncExitStack()
|
|
144
|
+
stop = asyncio.Event()
|
|
145
|
+
try:
|
|
146
|
+
server_type = config.get("type", "").lower()
|
|
147
|
+
if server_type == "stdio":
|
|
148
|
+
params = StdioServerParameters(
|
|
149
|
+
command=config["command"],
|
|
150
|
+
args=config.get("args", []),
|
|
151
|
+
env=config.get("env"),
|
|
152
|
+
)
|
|
153
|
+
transport = await stack.enter_async_context(stdio_client(params))
|
|
154
|
+
session = await stack.enter_async_context(
|
|
155
|
+
ClientSession(transport[0], transport[1])
|
|
156
|
+
)
|
|
157
|
+
elif server_type == "sse":
|
|
158
|
+
transport = await stack.enter_async_context(sse_client(config["url"]))
|
|
159
|
+
session = await stack.enter_async_context(
|
|
160
|
+
ClientSession(transport[0], transport[1])
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
raise ValueError(f"Unsupported server type: {server_type}")
|
|
164
|
+
|
|
165
|
+
await session.initialize()
|
|
166
|
+
res = await session.list_tools()
|
|
167
|
+
|
|
168
|
+
with self._lock:
|
|
169
|
+
self.servers[server_id] = {
|
|
170
|
+
"session": session,
|
|
171
|
+
"tools": res.tools,
|
|
172
|
+
"config": config,
|
|
173
|
+
"added_at": datetime.now(),
|
|
174
|
+
"stop": stop,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ready.set_result(True)
|
|
178
|
+
await stop.wait()
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"MCP Server Error [{server_id}]: {e}")
|
|
181
|
+
if not ready.done():
|
|
182
|
+
ready.set_result(False)
|
|
183
|
+
finally:
|
|
184
|
+
with self._lock:
|
|
185
|
+
self.servers.pop(server_id, None)
|
|
186
|
+
self._server_futures.pop(server_id, None)
|
|
187
|
+
try:
|
|
188
|
+
await stack.aclose()
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"MCP Cleanup Error [{server_id}]: {e}")
|
|
191
|
+
|
|
192
|
+
# --- 同步接口桥接 ---
|
|
193
|
+
|
|
194
|
+
def add_server(
|
|
195
|
+
self,
|
|
196
|
+
server_id: str,
|
|
197
|
+
config: Dict[str, Any],
|
|
198
|
+
max_retries: int = 1,
|
|
199
|
+
timeout: int = 90,
|
|
200
|
+
) -> bool:
|
|
201
|
+
"""添加并连接一个 MCP 服务器,失败时按 max_retries 重试"""
|
|
202
|
+
with self._lock:
|
|
203
|
+
if server_id in self.servers:
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
for attempt in range(1, max(1, max_retries) + 1):
|
|
207
|
+
ready: concurrent.futures.Future = concurrent.futures.Future()
|
|
208
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
209
|
+
self._serve_server(server_id, config, ready), self.loop
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
ok = ready.result(timeout=timeout)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.error(f"Sync add_server timeout/error [{server_id}]: {e}")
|
|
215
|
+
ok = False
|
|
216
|
+
|
|
217
|
+
if ok:
|
|
218
|
+
with self._lock:
|
|
219
|
+
self._server_futures[server_id] = future
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
if attempt < max_retries:
|
|
223
|
+
logger.warning(
|
|
224
|
+
f"MCP 添加服务器 '{server_id}' 第 {attempt} 次失败,重试中..."
|
|
225
|
+
)
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
def remove_server(self, server_id: str, timeout: int = 10) -> bool:
|
|
229
|
+
"""移除服务器并等待其资源(子进程/连接)释放"""
|
|
230
|
+
with self._lock:
|
|
231
|
+
info = self.servers.get(server_id)
|
|
232
|
+
if info is None:
|
|
233
|
+
return False
|
|
234
|
+
stop = info["stop"]
|
|
235
|
+
future = self._server_futures.get(server_id)
|
|
236
|
+
|
|
237
|
+
self.loop.call_soon_threadsafe(stop.set)
|
|
238
|
+
if future is not None:
|
|
239
|
+
try:
|
|
240
|
+
future.result(timeout=timeout)
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.error(f"Sync remove_server timeout/error [{server_id}]: {e}")
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _format_server_info(server_id: str, info: Dict[str, Any]) -> Dict[str, Any]:
|
|
247
|
+
return {
|
|
248
|
+
"server_id": server_id,
|
|
249
|
+
"config": info["config"],
|
|
250
|
+
"tools": [t.name for t in info["tools"]],
|
|
251
|
+
"added_at": info["added_at"].isoformat(),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def get_server_info(
|
|
255
|
+
self, server_id: Optional[str] = None
|
|
256
|
+
) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
|
|
257
|
+
"""获取已连接 MCP 服务器的信息;不传 server_id 时返回全部"""
|
|
258
|
+
with self._lock:
|
|
259
|
+
if server_id is not None:
|
|
260
|
+
info = self.servers.get(server_id)
|
|
261
|
+
return self._format_server_info(server_id, info) if info else None
|
|
262
|
+
return [
|
|
263
|
+
self._format_server_info(sid, info)
|
|
264
|
+
for sid, info in self.servers.items()
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
def call_tool(
|
|
268
|
+
self,
|
|
269
|
+
tool_name: str,
|
|
270
|
+
tool_args: Dict[str, Any],
|
|
271
|
+
server_id: Optional[str] = None,
|
|
272
|
+
timeout=60,
|
|
273
|
+
):
|
|
274
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
275
|
+
self._call_tool_async(tool_name, tool_args, server_id), self.loop
|
|
276
|
+
)
|
|
277
|
+
return future.result(timeout=timeout)
|
|
278
|
+
|
|
279
|
+
# --- 异步接口 (a开头,保持Tina风格) ---
|
|
280
|
+
|
|
281
|
+
async def acall_tool(
|
|
282
|
+
self, tool_name: str, tool_args: Dict[str, Any], server_id: Optional[str] = None
|
|
283
|
+
):
|
|
284
|
+
return await self._call_tool_async(tool_name, tool_args, server_id)
|
|
285
|
+
|
|
286
|
+
async def _call_tool_async(
|
|
287
|
+
self, tool_name: str, tool_args: Dict[str, Any], server_id: Optional[str] = None
|
|
288
|
+
):
|
|
289
|
+
target_sid = server_id
|
|
290
|
+
# 自动寻址逻辑
|
|
291
|
+
if not target_sid:
|
|
292
|
+
with self._lock:
|
|
293
|
+
for sid, info in self.servers.items():
|
|
294
|
+
if any(t.name == tool_name for t in info["tools"]):
|
|
295
|
+
target_sid = sid
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
if not target_sid or target_sid not in self.servers:
|
|
299
|
+
return {"success": False, "error": f"Tool {tool_name} not found"}
|
|
300
|
+
|
|
301
|
+
session = self.servers[target_sid]["session"]
|
|
302
|
+
try:
|
|
303
|
+
# 记录历史
|
|
304
|
+
res = await session.call_tool(tool_name, tool_args)
|
|
305
|
+
# 这里统一处理结果,避免 TextContent 对象导致后续 JSON 序列化失败
|
|
306
|
+
|
|
307
|
+
history_item = {
|
|
308
|
+
"id": str(uuid.uuid4()),
|
|
309
|
+
"timestamp": datetime.now().isoformat(),
|
|
310
|
+
"tool": tool_name,
|
|
311
|
+
"server": target_sid,
|
|
312
|
+
"success": True,
|
|
313
|
+
}
|
|
314
|
+
self.request_history.append(history_item)
|
|
315
|
+
|
|
316
|
+
return {"success": True, "content": res.content, "server_id": target_sid}
|
|
317
|
+
except Exception as e:
|
|
318
|
+
return {"success": False, "error": str(e)}
|
|
319
|
+
|
|
320
|
+
def to_tina_tools(self):
|
|
321
|
+
"""
|
|
322
|
+
将当前已连接的所有 MCP 工具转换为 Tina 格式
|
|
323
|
+
确保指定 name="mcp" 以避免 ToolsNotNamed 异常
|
|
324
|
+
"""
|
|
325
|
+
from ..agent.core.tools import Tools
|
|
326
|
+
|
|
327
|
+
# 必须指定 name,这样 self.tools = _tools + self.tools 才能正常工作
|
|
328
|
+
tina_tools = Tools(name="mcp")
|
|
329
|
+
|
|
330
|
+
with self._lock:
|
|
331
|
+
for sid, info in self.servers.items():
|
|
332
|
+
for tool in info["tools"]:
|
|
333
|
+
# 注意:Tina Tools 内部可能还会根据包名加一层前缀
|
|
334
|
+
# 这里的 register_no_function 保持你习惯的格式
|
|
335
|
+
schema = _tool_input_schema(tool)
|
|
336
|
+
tina_tools.register_no_function(
|
|
337
|
+
name=f"{sid}_{tool.name}", # 外部包名是mcp,内部就是 mcp_sid_name
|
|
338
|
+
description=f"[MCP:{sid}] {tool.description}",
|
|
339
|
+
parameters=_convert_input_schema(schema),
|
|
340
|
+
required_parameters=schema.get("required", []) or [],
|
|
341
|
+
)
|
|
342
|
+
return tina_tools
|
|
343
|
+
|
|
344
|
+
def close(self):
|
|
345
|
+
"""优雅关闭所有资源"""
|
|
346
|
+
if not self._loop_is_running:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
with self._lock:
|
|
350
|
+
ids = list(self.servers.keys())
|
|
351
|
+
|
|
352
|
+
# 通知各 server 的常驻 task 退出,并在原 task 内完成资源清理
|
|
353
|
+
for sid in ids:
|
|
354
|
+
self.remove_server(sid)
|
|
355
|
+
|
|
356
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
357
|
+
|
|
358
|
+
def __del__(self):
|
|
359
|
+
# 析构时尽量尝试静默关闭
|
|
360
|
+
try:
|
|
361
|
+
if hasattr(self, "loop") and self.loop.is_running():
|
|
362
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
363
|
+
except:
|
|
364
|
+
pass
|
|
@@ -3,6 +3,15 @@ from ..core import logger
|
|
|
3
3
|
from typing import Dict, Any
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
def _content_to_text(result: Dict[str, Any]) -> str:
|
|
7
|
+
"""将 MCP 返回的 TextContent 列表拼接为纯文本"""
|
|
8
|
+
parts = []
|
|
9
|
+
for item in result.get("content") or []:
|
|
10
|
+
text = getattr(item, "text", None)
|
|
11
|
+
parts.append(text if text is not None else str(item))
|
|
12
|
+
return "\n".join(parts)
|
|
13
|
+
|
|
14
|
+
|
|
6
15
|
class MCPToolExecutor:
|
|
7
16
|
"""
|
|
8
17
|
MCP工具执行器,用于执行MCP工具调用
|
|
@@ -36,7 +45,7 @@ class MCPToolExecutor:
|
|
|
36
45
|
logger.debug(
|
|
37
46
|
f"MCPToolExecutor - 工具 '{_tool_name}' 执行结果: {result}:参数 {_tool_args}"
|
|
38
47
|
)
|
|
39
|
-
return result
|
|
48
|
+
return _content_to_text(result)
|
|
40
49
|
else:
|
|
41
50
|
logger.error(
|
|
42
51
|
f"MCPToolExecutor - 工具'{_tool_name}' 执行错误: {result.get('error', '未知错误')}"
|
|
@@ -57,7 +66,7 @@ class MCPToolExecutor:
|
|
|
57
66
|
logger.debug(
|
|
58
67
|
f"MCPToolExecutor - 工具 '{_tool_name}' 执行结果: {result}:参数 {_tool_args}"
|
|
59
68
|
)
|
|
60
|
-
return result
|
|
69
|
+
return _content_to_text(result)
|
|
61
70
|
else:
|
|
62
71
|
logger.error(
|
|
63
72
|
f"MCPToolExecutor - 工具'{_tool_name}' 执行错误: {result.get('error', '未知错误')}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tina-python
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.6
|
|
4
4
|
Summary: tina is in your computer!
|
|
5
5
|
Home-page: https://gitee.com/wang-churi/tina
|
|
6
6
|
Author: 王出日
|
|
@@ -20,11 +20,14 @@ Requires-Dist: python-dotenv
|
|
|
20
20
|
Provides-Extra: tui
|
|
21
21
|
Requires-Dist: textual>=0.80; extra == "tui"
|
|
22
22
|
Provides-Extra: mcp
|
|
23
|
-
Requires-Dist: mcp
|
|
23
|
+
Requires-Dist: mcp; extra == "mcp"
|
|
24
24
|
Provides-Extra: test
|
|
25
25
|
Requires-Dist: pytest>=9.0; extra == "test"
|
|
26
26
|
Requires-Dist: pytest-asyncio>=1.4; extra == "test"
|
|
27
27
|
Requires-Dist: pytest-cov>=7.0; extra == "test"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: textual>=0.80; extra == "all"
|
|
30
|
+
Requires-Dist: mcp; extra == "all"
|
|
28
31
|
Dynamic: author
|
|
29
32
|
Dynamic: home-page
|
|
30
33
|
Dynamic: license-file
|
|
@@ -47,6 +50,12 @@ pip install tina-python
|
|
|
47
50
|
# 如果需要使用 MCP(模型上下文协议)服务
|
|
48
51
|
pip install tina-python[mcp]
|
|
49
52
|
|
|
53
|
+
# 如果需要 TUI 终端界面
|
|
54
|
+
pip install tina-python[tui]
|
|
55
|
+
|
|
56
|
+
# 一次装齐所有可选功能(TUI + MCP)
|
|
57
|
+
pip install tina-python[all]
|
|
58
|
+
|
|
50
59
|
# 如果需要运行测试
|
|
51
60
|
pip install tina-python[test]
|
|
52
61
|
```
|
|
@@ -208,8 +217,9 @@ result = await llm.apredict(
|
|
|
208
217
|
stream=False
|
|
209
218
|
)
|
|
210
219
|
|
|
211
|
-
#
|
|
212
|
-
|
|
220
|
+
# 异步流式:先 await 拿到异步生成器
|
|
221
|
+
result = await llm.apredict(input_text="讲个故事", stream=True)
|
|
222
|
+
async for chunk in result:
|
|
213
223
|
print(chunk["content"], end="")
|
|
214
224
|
```
|
|
215
225
|
|
|
@@ -239,8 +249,6 @@ async for chunk in llm.apredict(input_text="讲个故事", stream=True):
|
|
|
239
249
|
{
|
|
240
250
|
"role": "assistant",
|
|
241
251
|
"content": "回复内容",
|
|
242
|
-
# 推理模型时会出现
|
|
243
|
-
"reasoning_content": "推理链...",
|
|
244
252
|
# 有工具调用时会出现
|
|
245
253
|
"tool_calls": [
|
|
246
254
|
{
|
|
@@ -252,7 +260,8 @@ async for chunk in llm.apredict(input_text="讲个故事", stream=True):
|
|
|
252
260
|
]
|
|
253
261
|
}
|
|
254
262
|
|
|
255
|
-
#
|
|
263
|
+
# 流式:逐块返回片段,工具调用以 tool_name / tool_arguments 片段给出;
|
|
264
|
+
# 推理模型的思维链在 reasoning_content 中,末尾可能带有 usage
|
|
256
265
|
```
|
|
257
266
|
|
|
258
267
|
### 1.8 获取可用模型列表
|
|
@@ -389,9 +398,12 @@ tool_calls = [
|
|
|
389
398
|
}
|
|
390
399
|
}
|
|
391
400
|
]
|
|
392
|
-
|
|
401
|
+
# execute 需要事件对象(Agent 内部会自动传入)
|
|
402
|
+
from tina import AgentEvents
|
|
403
|
+
|
|
404
|
+
results = tools.execute(tool_calls, events=AgentEvents())
|
|
393
405
|
# [
|
|
394
|
-
# {"role": "tool", "tool_call_id": "call_123", "
|
|
406
|
+
# {"role": "tool", "tool_call_id": "call_123", "tool_name": "get_weather", "content": "北京今天天气晴朗"}
|
|
395
407
|
# ]
|
|
396
408
|
```
|
|
397
409
|
|
|
@@ -539,7 +551,7 @@ elif agent.state == AgentState.ERROR:
|
|
|
539
551
|
# 获取消息列表
|
|
540
552
|
messages = agent.get_messages()
|
|
541
553
|
|
|
542
|
-
#
|
|
554
|
+
# 清空全部消息(含系统提示词,之后需重新 set_system_prompt)
|
|
543
555
|
agent.clear_messages()
|
|
544
556
|
|
|
545
557
|
# 获取系统提示词
|
|
@@ -627,7 +639,7 @@ def on_turn_end():
|
|
|
627
639
|
agent_a = Agent(llm=llm, tools=tools, name="agent_a")
|
|
628
640
|
agent_b = Agent(llm=llm, tools=tools, name="agent_b")
|
|
629
641
|
|
|
630
|
-
#
|
|
642
|
+
# 关联另一个 Agent(记录到 other_agents,目前不会自动共享消息列表)
|
|
631
643
|
agent_a.connect_agent(agent_b)
|
|
632
644
|
|
|
633
645
|
# 断开连接
|
|
@@ -692,6 +704,8 @@ tina 支持通过 MCP(Model Context Protocol)连接外部工具生态。
|
|
|
692
704
|
pip install tina-python[mcp]
|
|
693
705
|
```
|
|
694
706
|
|
|
707
|
+
> 想一次装齐所有可选功能(TUI + MCP):`pip install tina-python[all]`
|
|
708
|
+
|
|
695
709
|
### 4.2 使用 MCP 客户端
|
|
696
710
|
|
|
697
711
|
```python
|
|
@@ -884,8 +898,8 @@ from tina import Agent, Tools
|
|
|
884
898
|
from tina.llm import BaseAPI
|
|
885
899
|
from tina.utils.system_tools import system_tools
|
|
886
900
|
|
|
887
|
-
tools = Tools()
|
|
888
|
-
tools += system_tools # 添加 tina
|
|
901
|
+
tools = Tools(name="my_tools")
|
|
902
|
+
tools += system_tools # 添加 tina 自带的系统工具包(工具集需命名)
|
|
889
903
|
|
|
890
904
|
agent = Agent(
|
|
891
905
|
llm=BaseAPI(),
|
|
@@ -987,7 +1001,7 @@ run_agent_in_cli(agent)
|
|
|
987
1001
|
|------|------|
|
|
988
1002
|
| `#context` | 查看当前对话上下文(消息列表) |
|
|
989
1003
|
| `#clear` | 清屏 |
|
|
990
|
-
|
|
|
1004
|
+
| `exit` / `quit` | 退出对话(`#exit` 不支持,会被当作普通输入) |
|
|
991
1005
|
|
|
992
1006
|
### 6.4 多模态消息构建器 `build_multimodal_message`
|
|
993
1007
|
|
|
@@ -1077,7 +1091,7 @@ MAX_INPUT=8000
|
|
|
1077
1091
|
| 用途 | 依赖 |
|
|
1078
1092
|
|------|------|
|
|
1079
1093
|
| 核心功能 | `httpx`, `python-dotenv` |
|
|
1080
|
-
| MCP 支持 | `mcp`
|
|
1094
|
+
| MCP 支持 | `mcp` |
|
|
1081
1095
|
| TUI 界面(可选) | `textual` |
|
|
1082
1096
|
| 测试 | `pytest`, `pytest-asyncio`, `pytest-cov` |
|
|
1083
1097
|
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import json
|
|
3
|
-
import uuid
|
|
4
|
-
import threading
|
|
5
|
-
from typing import Dict, List, Any, Optional, Union, Tuple
|
|
6
|
-
from contextlib import AsyncExitStack
|
|
7
|
-
from datetime import datetime
|
|
8
|
-
from ..core import logger # 假设你有统一的logger
|
|
9
|
-
|
|
10
|
-
try:
|
|
11
|
-
from mcp import ClientSession, StdioServerParameters
|
|
12
|
-
from mcp.client.stdio import stdio_client
|
|
13
|
-
from mcp.client.sse import sse_client
|
|
14
|
-
|
|
15
|
-
MCP_AVAILABLE = True
|
|
16
|
-
except ImportError:
|
|
17
|
-
MCP_AVAILABLE = False
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _tool_input_schema(tool: Any) -> Dict[str, Any]:
|
|
21
|
-
"""获取 MCP 工具的输入 schema,兼容 SDK 的字段改名
|
|
22
|
-
|
|
23
|
-
MCP Python SDK 早期版本用驼峰 `inputSchema`,新版本(pydantic 模型)改为
|
|
24
|
-
下划线 `input_schema`。这里两种都取,取不到返回空 dict。
|
|
25
|
-
"""
|
|
26
|
-
schema = getattr(tool, "input_schema", None)
|
|
27
|
-
if schema is None:
|
|
28
|
-
schema = getattr(tool, "inputSchema", None)
|
|
29
|
-
return schema or {}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class MCPClient:
|
|
33
|
-
def __init__(self):
|
|
34
|
-
if not MCP_AVAILABLE:
|
|
35
|
-
raise ImportError("请先安装MCP依赖: pip install mcp-python")
|
|
36
|
-
|
|
37
|
-
self.servers = (
|
|
38
|
-
{}
|
|
39
|
-
) # {server_id: {"session": s, "stack": stack, "tools": [], "config": {}}}
|
|
40
|
-
self.request_history = []
|
|
41
|
-
self.loop = asyncio.new_event_loop()
|
|
42
|
-
self._loop_is_running = False
|
|
43
|
-
self._lock = threading.Lock() # 保护 servers 字典的线程安全
|
|
44
|
-
|
|
45
|
-
self.start_loop_thread()
|
|
46
|
-
|
|
47
|
-
def start_loop_thread(self):
|
|
48
|
-
"""启动专用后台线程运行事件循环"""
|
|
49
|
-
|
|
50
|
-
def run_event_loop():
|
|
51
|
-
asyncio.set_event_loop(self.loop)
|
|
52
|
-
self._loop_is_running = True
|
|
53
|
-
try:
|
|
54
|
-
self.loop.run_forever()
|
|
55
|
-
finally:
|
|
56
|
-
# 最后的清理逻辑
|
|
57
|
-
self._loop_is_running = False
|
|
58
|
-
self.loop.close()
|
|
59
|
-
|
|
60
|
-
thread = threading.Thread(target=run_event_loop, daemon=True)
|
|
61
|
-
thread.start()
|
|
62
|
-
|
|
63
|
-
# --- 核心内部异步实现 (解决资源残留) ---
|
|
64
|
-
|
|
65
|
-
async def _add_server_async(self, server_id: str, config: Dict[str, Any]) -> bool:
|
|
66
|
-
if server_id in self.servers:
|
|
67
|
-
return True
|
|
68
|
-
|
|
69
|
-
# 为每个 Server 创建独立的 Stack
|
|
70
|
-
stack = AsyncExitStack()
|
|
71
|
-
try:
|
|
72
|
-
server_type = config.get("type", "").lower()
|
|
73
|
-
if server_type == "stdio":
|
|
74
|
-
params = StdioServerParameters(
|
|
75
|
-
command=config["command"],
|
|
76
|
-
args=config.get("args", []),
|
|
77
|
-
env=config.get("env"),
|
|
78
|
-
)
|
|
79
|
-
# 进入 stdio 上下文 (管理子进程)
|
|
80
|
-
transport = await stack.enter_async_context(stdio_client(params))
|
|
81
|
-
# 进入 session 上下文 (管理协议)
|
|
82
|
-
session = await stack.enter_async_context(
|
|
83
|
-
ClientSession(transport[0], transport[1])
|
|
84
|
-
)
|
|
85
|
-
elif server_type == "sse":
|
|
86
|
-
transport = await stack.enter_async_context(sse_client(config["url"]))
|
|
87
|
-
session = await stack.enter_async_context(
|
|
88
|
-
ClientSession(transport[0], transport[1])
|
|
89
|
-
)
|
|
90
|
-
else:
|
|
91
|
-
raise ValueError(f"Unsupported server type: {server_type}")
|
|
92
|
-
|
|
93
|
-
await session.initialize()
|
|
94
|
-
res = await session.list_tools()
|
|
95
|
-
|
|
96
|
-
with self._lock:
|
|
97
|
-
self.servers[server_id] = {
|
|
98
|
-
"session": session,
|
|
99
|
-
"stack": stack, # 这里的 stack 包含了这个 server 的所有资源
|
|
100
|
-
"tools": res.tools,
|
|
101
|
-
"config": config,
|
|
102
|
-
"added_at": datetime.now(),
|
|
103
|
-
}
|
|
104
|
-
return True
|
|
105
|
-
except Exception as e:
|
|
106
|
-
await stack.aclose() # 出错时立即清理已打开的资源
|
|
107
|
-
logger.error(f"MCP Add Server Error [{server_id}]: {e}")
|
|
108
|
-
return False
|
|
109
|
-
|
|
110
|
-
async def _close_server_async(self, server_id: str) -> bool:
|
|
111
|
-
"""物理杀掉子进程并移除服务器"""
|
|
112
|
-
with self._lock:
|
|
113
|
-
server_info = self.servers.pop(server_id, None)
|
|
114
|
-
|
|
115
|
-
if server_info:
|
|
116
|
-
# 这一步是关键:调用 stack.aclose() 会按相反顺序关闭 session 和 transport
|
|
117
|
-
# 对于 stdio 来说,这会发送信号给子进程并等待其退出
|
|
118
|
-
await server_info["stack"].aclose()
|
|
119
|
-
return True
|
|
120
|
-
return False
|
|
121
|
-
|
|
122
|
-
# --- 同步接口桥接 ---
|
|
123
|
-
|
|
124
|
-
def add_server(self, server_id: str, config: Dict[str, Any], timeout=30) -> bool:
|
|
125
|
-
future = asyncio.run_coroutine_threadsafe(
|
|
126
|
-
self._add_server_async(server_id, config), self.loop
|
|
127
|
-
)
|
|
128
|
-
try:
|
|
129
|
-
return future.result(timeout=timeout)
|
|
130
|
-
except Exception as e:
|
|
131
|
-
logger.error(f"Sync add_server timeout/error: {e}")
|
|
132
|
-
return False
|
|
133
|
-
|
|
134
|
-
def call_tool(
|
|
135
|
-
self,
|
|
136
|
-
tool_name: str,
|
|
137
|
-
tool_args: Dict[str, Any],
|
|
138
|
-
server_id: Optional[str] = None,
|
|
139
|
-
timeout=60,
|
|
140
|
-
):
|
|
141
|
-
future = asyncio.run_coroutine_threadsafe(
|
|
142
|
-
self._call_tool_async(tool_name, tool_args, server_id), self.loop
|
|
143
|
-
)
|
|
144
|
-
return future.result(timeout=timeout)
|
|
145
|
-
|
|
146
|
-
# --- 异步接口 (a开头,保持Tina风格) ---
|
|
147
|
-
|
|
148
|
-
async def acall_tool(
|
|
149
|
-
self, tool_name: str, tool_args: Dict[str, Any], server_id: Optional[str] = None
|
|
150
|
-
):
|
|
151
|
-
return await self._call_tool_async(tool_name, tool_args, server_id)
|
|
152
|
-
|
|
153
|
-
async def _call_tool_async(
|
|
154
|
-
self, tool_name: str, tool_args: Dict[str, Any], server_id: Optional[str] = None
|
|
155
|
-
):
|
|
156
|
-
target_sid = server_id
|
|
157
|
-
# 自动寻址逻辑
|
|
158
|
-
if not target_sid:
|
|
159
|
-
with self._lock:
|
|
160
|
-
for sid, info in self.servers.items():
|
|
161
|
-
if any(t.name == tool_name for t in info["tools"]):
|
|
162
|
-
target_sid = sid
|
|
163
|
-
break
|
|
164
|
-
|
|
165
|
-
if not target_sid or target_sid not in self.servers:
|
|
166
|
-
return {"success": False, "error": f"Tool {tool_name} not found"}
|
|
167
|
-
|
|
168
|
-
session = self.servers[target_sid]["session"]
|
|
169
|
-
try:
|
|
170
|
-
# 记录历史
|
|
171
|
-
res = await session.call_tool(tool_name, tool_args)
|
|
172
|
-
# 这里统一处理结果,避免 TextContent 对象导致后续 JSON 序列化失败
|
|
173
|
-
|
|
174
|
-
history_item = {
|
|
175
|
-
"id": str(uuid.uuid4()),
|
|
176
|
-
"timestamp": datetime.now().isoformat(),
|
|
177
|
-
"tool": tool_name,
|
|
178
|
-
"server": target_sid,
|
|
179
|
-
"success": True,
|
|
180
|
-
}
|
|
181
|
-
self.request_history.append(history_item)
|
|
182
|
-
|
|
183
|
-
return {"success": True, "content": res.content, "server_id": target_sid}
|
|
184
|
-
except Exception as e:
|
|
185
|
-
return {"success": False, "error": str(e)}
|
|
186
|
-
|
|
187
|
-
def to_tina_tools(self):
|
|
188
|
-
"""
|
|
189
|
-
将当前已连接的所有 MCP 工具转换为 Tina 格式
|
|
190
|
-
确保指定 name="mcp" 以避免 ToolsNotNamed 异常
|
|
191
|
-
"""
|
|
192
|
-
from ..agent.core.tools import Tools
|
|
193
|
-
|
|
194
|
-
# 必须指定 name,这样 self.tools = _tools + self.tools 才能正常工作
|
|
195
|
-
tina_tools = Tools(name="mcp")
|
|
196
|
-
|
|
197
|
-
with self._lock:
|
|
198
|
-
for sid, info in self.servers.items():
|
|
199
|
-
for tool in info["tools"]:
|
|
200
|
-
# 注意:Tina Tools 内部可能还会根据包名加一层前缀
|
|
201
|
-
# 这里的 register_no_function 保持你习惯的格式
|
|
202
|
-
schema = _tool_input_schema(tool)
|
|
203
|
-
properties = schema.get("properties", {}) or {}
|
|
204
|
-
tina_tools.register_no_function(
|
|
205
|
-
name=f"{sid}_{tool.name}", # 外部包名是mcp,内部就是 mcp_sid_name
|
|
206
|
-
description=f"[MCP:{sid}] {tool.description}",
|
|
207
|
-
parameters={
|
|
208
|
-
k: {
|
|
209
|
-
"type": v.get("type", "str"),
|
|
210
|
-
"description": v.get("description", ""),
|
|
211
|
-
}
|
|
212
|
-
for k, v in properties.items()
|
|
213
|
-
},
|
|
214
|
-
required_parameters=schema.get("required", []) or [],
|
|
215
|
-
)
|
|
216
|
-
return tina_tools
|
|
217
|
-
|
|
218
|
-
def close(self):
|
|
219
|
-
"""优雅关闭所有资源"""
|
|
220
|
-
if self._loop_is_running:
|
|
221
|
-
# 获取所有服务器 ID
|
|
222
|
-
with self._lock:
|
|
223
|
-
ids = list(self.servers.keys())
|
|
224
|
-
|
|
225
|
-
# 提交清理任务
|
|
226
|
-
tasks = [self._close_server_async(sid) for sid in ids]
|
|
227
|
-
future = asyncio.run_coroutine_threadsafe(asyncio.gather(*tasks), self.loop)
|
|
228
|
-
future.result(timeout=10)
|
|
229
|
-
|
|
230
|
-
# 停止循环
|
|
231
|
-
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
232
|
-
|
|
233
|
-
def __del__(self):
|
|
234
|
-
# 析构时尽量尝试静默关闭
|
|
235
|
-
try:
|
|
236
|
-
if hasattr(self, "loop") and self.loop.is_running():
|
|
237
|
-
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
238
|
-
except:
|
|
239
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|