autoglm-gui 1.3.1__py3-none-any.whl → 1.4.1__py3-none-any.whl

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.
Files changed (61) hide show
  1. AutoGLM_GUI/__main__.py +0 -4
  2. AutoGLM_GUI/adb_plus/qr_pair.py +8 -8
  3. AutoGLM_GUI/agents/__init__.py +20 -0
  4. AutoGLM_GUI/agents/factory.py +160 -0
  5. AutoGLM_GUI/agents/mai_adapter.py +627 -0
  6. AutoGLM_GUI/agents/protocols.py +23 -0
  7. AutoGLM_GUI/api/__init__.py +50 -7
  8. AutoGLM_GUI/api/agents.py +61 -19
  9. AutoGLM_GUI/api/devices.py +12 -18
  10. AutoGLM_GUI/api/dual_model.py +24 -17
  11. AutoGLM_GUI/api/health.py +13 -0
  12. AutoGLM_GUI/api/layered_agent.py +659 -0
  13. AutoGLM_GUI/api/mcp.py +11 -10
  14. AutoGLM_GUI/api/version.py +23 -10
  15. AutoGLM_GUI/api/workflows.py +2 -1
  16. AutoGLM_GUI/config_manager.py +56 -24
  17. AutoGLM_GUI/device_adapter.py +263 -0
  18. AutoGLM_GUI/device_protocol.py +266 -0
  19. AutoGLM_GUI/devices/__init__.py +49 -0
  20. AutoGLM_GUI/devices/adb_device.py +205 -0
  21. AutoGLM_GUI/devices/mock_device.py +183 -0
  22. AutoGLM_GUI/devices/remote_device.py +172 -0
  23. AutoGLM_GUI/dual_model/decision_model.py +4 -4
  24. AutoGLM_GUI/dual_model/protocols.py +3 -3
  25. AutoGLM_GUI/exceptions.py +3 -3
  26. AutoGLM_GUI/mai_ui_adapter/agent_wrapper.py +291 -0
  27. AutoGLM_GUI/metrics.py +13 -20
  28. AutoGLM_GUI/phone_agent_manager.py +219 -134
  29. AutoGLM_GUI/phone_agent_patches.py +2 -1
  30. AutoGLM_GUI/platform_utils.py +5 -2
  31. AutoGLM_GUI/prompts.py +6 -1
  32. AutoGLM_GUI/schemas.py +45 -14
  33. AutoGLM_GUI/scrcpy_stream.py +17 -13
  34. AutoGLM_GUI/server.py +3 -1
  35. AutoGLM_GUI/socketio_server.py +16 -4
  36. AutoGLM_GUI/state.py +10 -30
  37. AutoGLM_GUI/static/assets/{about-Cj6QXqMf.js → about-_XNhzQZX.js} +1 -1
  38. AutoGLM_GUI/static/assets/chat-DwJpiAWf.js +126 -0
  39. AutoGLM_GUI/static/assets/{dialog-CxJlnjzH.js → dialog-B3uW4T8V.js} +3 -3
  40. AutoGLM_GUI/static/assets/index-Cpv2gSF1.css +1 -0
  41. AutoGLM_GUI/static/assets/{index-C_B-Arvf.js → index-Cy8TmmHV.js} +1 -1
  42. AutoGLM_GUI/static/assets/{index-CxJQuE4y.js → index-UYYauTly.js} +6 -6
  43. AutoGLM_GUI/static/assets/{workflows-BTiGCNI0.js → workflows-Du_de-dt.js} +1 -1
  44. AutoGLM_GUI/static/index.html +2 -2
  45. AutoGLM_GUI/types.py +125 -0
  46. {autoglm_gui-1.3.1.dist-info → autoglm_gui-1.4.1.dist-info}/METADATA +147 -65
  47. {autoglm_gui-1.3.1.dist-info → autoglm_gui-1.4.1.dist-info}/RECORD +58 -39
  48. mai_agent/base.py +137 -0
  49. mai_agent/mai_grounding_agent.py +263 -0
  50. mai_agent/mai_naivigation_agent.py +526 -0
  51. mai_agent/prompt.py +148 -0
  52. mai_agent/unified_memory.py +67 -0
  53. mai_agent/utils.py +73 -0
  54. phone_agent/config/prompts.py +6 -1
  55. phone_agent/config/prompts_zh.py +6 -1
  56. AutoGLM_GUI/config.py +0 -23
  57. AutoGLM_GUI/static/assets/chat-BJeomZgh.js +0 -124
  58. AutoGLM_GUI/static/assets/index-Z0uYCPOO.css +0 -1
  59. {autoglm_gui-1.3.1.dist-info → autoglm_gui-1.4.1.dist-info}/WHEEL +0 -0
  60. {autoglm_gui-1.3.1.dist-info → autoglm_gui-1.4.1.dist-info}/entry_points.txt +0 -0
  61. {autoglm_gui-1.3.1.dist-info → autoglm_gui-1.4.1.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/api/mcp.py CHANGED
@@ -1,11 +1,19 @@
1
1
  """MCP (Model Context Protocol) tools for AutoGLM-GUI."""
2
2
 
3
- from typing import Any, Dict, List
3
+ from typing_extensions import TypedDict
4
4
 
5
5
  from fastmcp import FastMCP
6
6
 
7
7
  from AutoGLM_GUI.logger import logger
8
8
  from AutoGLM_GUI.prompts import MCP_SYSTEM_PROMPT_ZH
9
+ from AutoGLM_GUI.schemas import DeviceResponse
10
+
11
+
12
+ class ChatResult(TypedDict):
13
+ result: str
14
+ steps: int
15
+ success: bool
16
+
9
17
 
10
18
  # 创建 MCP 服务器实例
11
19
  mcp = FastMCP("AutoGLM-GUI MCP Server")
@@ -15,7 +23,7 @@ MCP_MAX_STEPS = 5
15
23
 
16
24
 
17
25
  @mcp.tool()
18
- def chat(device_id: str, message: str) -> Dict[str, Any]:
26
+ def chat(device_id: str, message: str) -> ChatResult:
19
27
  """
20
28
  Send a task to the AutoGLM Phone Agent for execution.
21
29
 
@@ -26,13 +34,6 @@ def chat(device_id: str, message: str) -> Dict[str, Any]:
26
34
  Args:
27
35
  device_id: Device identifier (e.g., "192.168.1.100:5555" or serial)
28
36
  message: Natural language task (e.g., "打开微信", "发送消息")
29
-
30
- Returns:
31
- {
32
- "result": str, # Task execution result
33
- "steps": int, # Number of steps taken
34
- "success": bool # Success flag
35
- }
36
37
  """
37
38
  from AutoGLM_GUI.exceptions import DeviceBusyError
38
39
  from AutoGLM_GUI.phone_agent_manager import PhoneAgentManager
@@ -84,7 +85,7 @@ def chat(device_id: str, message: str) -> Dict[str, Any]:
84
85
 
85
86
 
86
87
  @mcp.tool()
87
- def list_devices() -> List[Dict[str, Any]]:
88
+ def list_devices() -> list[DeviceResponse]:
88
89
  """
89
90
  List all connected ADB devices and their agent status.
90
91
 
@@ -3,8 +3,9 @@
3
3
  import json
4
4
  import re
5
5
  import time
6
+ import urllib.error
6
7
  import urllib.request
7
- from typing import Any
8
+ from typing_extensions import TypedDict
8
9
 
9
10
  from fastapi import APIRouter
10
11
 
@@ -12,13 +13,30 @@ from AutoGLM_GUI.logger import logger
12
13
  from AutoGLM_GUI.schemas import VersionCheckResponse
13
14
  from AutoGLM_GUI.version import APP_VERSION
14
15
 
16
+
17
+ class GitHubRelease(TypedDict, total=False):
18
+ """GitHub Release API response structure."""
19
+
20
+ tag_name: str
21
+ html_url: str
22
+ published_at: str
23
+
24
+
25
+ class _VersionCache(TypedDict):
26
+ """Internal cache structure for version checking."""
27
+
28
+ data: VersionCheckResponse | None
29
+ timestamp: float
30
+ ttl: int
31
+
32
+
15
33
  router = APIRouter()
16
34
 
17
35
  # In-memory cache for version check results
18
- _version_cache: dict[str, Any] = {
36
+ _version_cache: _VersionCache = {
19
37
  "data": None,
20
38
  "timestamp": 0,
21
- "ttl": 3600, # 1 hour cache TTL
39
+ "ttl": 3600,
22
40
  }
23
41
 
24
42
  # GitHub repository information
@@ -74,13 +92,8 @@ def compare_versions(current: str, latest: str) -> bool:
74
92
  return latest_tuple > current_tuple
75
93
 
76
94
 
77
- def fetch_latest_release() -> dict[str, Any] | None:
78
- """
79
- Fetch latest release information from GitHub API.
80
-
81
- Returns:
82
- Release data dict with 'tag_name', 'html_url', 'published_at' or None on error
83
- """
95
+ def fetch_latest_release() -> GitHubRelease | None:
96
+ """Fetch latest release information from GitHub API."""
84
97
  try:
85
98
  # Create request with User-Agent header (required by GitHub API)
86
99
  req = urllib.request.Request(
@@ -17,7 +17,8 @@ def list_workflows() -> WorkflowListResponse:
17
17
  """获取所有 workflows."""
18
18
  from AutoGLM_GUI.workflow_manager import workflow_manager
19
19
 
20
- workflows = workflow_manager.list_workflows()
20
+ workflow_dicts = workflow_manager.list_workflows()
21
+ workflows = [WorkflowResponse(**wf) for wf in workflow_dicts]
21
22
  return WorkflowListResponse(workflows=workflows)
22
23
 
23
24
 
@@ -54,12 +54,26 @@ class ConfigModel(BaseModel):
54
54
 
55
55
  # 双模型配置
56
56
  dual_model_enabled: bool = False
57
- decision_base_url: str = "https://api-inference.modelscope.cn/v1"
58
- decision_model_name: str = "ZhipuAI/GLM-4.7"
57
+ decision_base_url: str = ""
58
+ decision_model_name: str = ""
59
59
  decision_api_key: str = ""
60
60
 
61
- # 思考模式配置
62
- thinking_mode: str = "deep" # "fast" "deep"
61
+ # Agent 类型配置
62
+ agent_type: str = "glm" # Agent type (e.g., "glm", "mai")
63
+ agent_config_params: dict | None = None # Agent-specific configuration
64
+
65
+ # Agent 执行配置
66
+ default_max_steps: int = 100 # 单次任务最大执行步数
67
+
68
+ @field_validator("default_max_steps")
69
+ @classmethod
70
+ def validate_default_max_steps(cls, v: int) -> int:
71
+ """验证 default_max_steps 范围."""
72
+ if v <= 0:
73
+ raise ValueError("default_max_steps must be positive")
74
+ if v > 1000:
75
+ raise ValueError("default_max_steps must be <= 1000")
76
+ return v
63
77
 
64
78
  @field_validator("base_url")
65
79
  @classmethod
@@ -85,14 +99,6 @@ class ConfigModel(BaseModel):
85
99
  raise ValueError("decision_base_url must start with http:// or https://")
86
100
  return v.rstrip("/") # 去除尾部斜杠
87
101
 
88
- @field_validator("thinking_mode")
89
- @classmethod
90
- def validate_thinking_mode(cls, v: str) -> str:
91
- """验证思考模式."""
92
- if v not in ("fast", "deep"):
93
- raise ValueError("thinking_mode must be 'fast' or 'deep'")
94
- return v
95
-
96
102
 
97
103
  # ==================== 配置层数据类 ====================
98
104
 
@@ -109,8 +115,11 @@ class ConfigLayer:
109
115
  decision_base_url: Optional[str] = None
110
116
  decision_model_name: Optional[str] = None
111
117
  decision_api_key: Optional[str] = None
112
- # 思考模式配置
113
- thinking_mode: Optional[str] = None
118
+ # Agent 类型配置
119
+ agent_type: Optional[str] = None
120
+ agent_config_params: Optional[dict] = None
121
+ # Agent 执行配置
122
+ default_max_steps: Optional[int] = None
114
123
 
115
124
  source: ConfigSource = ConfigSource.DEFAULT
116
125
 
@@ -142,7 +151,9 @@ class ConfigLayer:
142
151
  "decision_base_url": self.decision_base_url,
143
152
  "decision_model_name": self.decision_model_name,
144
153
  "decision_api_key": self.decision_api_key,
145
- "thinking_mode": self.thinking_mode,
154
+ "agent_type": self.agent_type,
155
+ "agent_config_params": self.agent_config_params,
156
+ "default_max_steps": self.default_max_steps,
146
157
  }.items()
147
158
  if v is not None
148
159
  }
@@ -202,6 +213,9 @@ class UnifiedConfigManager:
202
213
  base_url="",
203
214
  model_name="autoglm-phone-9b",
204
215
  api_key="EMPTY",
216
+ agent_type="glm",
217
+ agent_config_params=None,
218
+ default_max_steps=100,
205
219
  source=ConfigSource.DEFAULT,
206
220
  )
207
221
 
@@ -314,7 +328,11 @@ class UnifiedConfigManager:
314
328
  decision_base_url=config_data.get("decision_base_url"),
315
329
  decision_model_name=config_data.get("decision_model_name"),
316
330
  decision_api_key=config_data.get("decision_api_key"),
317
- thinking_mode=config_data.get("thinking_mode"),
331
+ agent_type=config_data.get(
332
+ "agent_type", "glm"
333
+ ), # 默认 'glm',兼容旧配置
334
+ agent_config_params=config_data.get("agent_config_params"),
335
+ default_max_steps=config_data.get("default_max_steps"),
318
336
  source=ConfigSource.FILE,
319
337
  )
320
338
  self._effective_config = None # 清除缓存
@@ -346,7 +364,9 @@ class UnifiedConfigManager:
346
364
  decision_base_url: Optional[str] = None,
347
365
  decision_model_name: Optional[str] = None,
348
366
  decision_api_key: Optional[str] = None,
349
- thinking_mode: Optional[str] = None,
367
+ agent_type: Optional[str] = None,
368
+ agent_config_params: Optional[dict] = None,
369
+ default_max_steps: Optional[int] = None,
350
370
  merge_mode: bool = True,
351
371
  ) -> bool:
352
372
  """
@@ -360,7 +380,9 @@ class UnifiedConfigManager:
360
380
  decision_base_url: 决策模型 Base URL
361
381
  decision_model_name: 决策模型名称
362
382
  decision_api_key: 决策模型 API key
363
- thinking_mode: 思考模式 (fast/deep)
383
+ agent_type: Agent 类型(可选,如 "glm", "mai")
384
+ agent_config_params: Agent 特定配置参数(可选)
385
+ default_max_steps: 默认最大执行步数(可选)
364
386
  merge_mode: 是否合并现有配置(True: 保留未提供的字段)
365
387
 
366
388
  Returns:
@@ -371,7 +393,7 @@ class UnifiedConfigManager:
371
393
  self._config_path.parent.mkdir(parents=True, exist_ok=True)
372
394
 
373
395
  # 准备新配置
374
- new_config = {
396
+ new_config: dict[str, str | bool | int | dict | None] = {
375
397
  "base_url": base_url,
376
398
  "model_name": model_name,
377
399
  }
@@ -386,8 +408,12 @@ class UnifiedConfigManager:
386
408
  new_config["decision_model_name"] = decision_model_name
387
409
  if decision_api_key:
388
410
  new_config["decision_api_key"] = decision_api_key
389
- if thinking_mode:
390
- new_config["thinking_mode"] = thinking_mode
411
+ if agent_type is not None:
412
+ new_config["agent_type"] = agent_type
413
+ if agent_config_params is not None:
414
+ new_config["agent_config_params"] = agent_config_params
415
+ if default_max_steps is not None:
416
+ new_config["default_max_steps"] = default_max_steps
391
417
 
392
418
  # 合并模式:保留现有文件中未提供的字段
393
419
  if merge_mode and self._config_path.exists():
@@ -402,7 +428,9 @@ class UnifiedConfigManager:
402
428
  "decision_base_url",
403
429
  "decision_model_name",
404
430
  "decision_api_key",
405
- "thinking_mode",
431
+ "agent_type",
432
+ "agent_config_params",
433
+ "default_max_steps",
406
434
  ]
407
435
  for key in preserve_keys:
408
436
  if key not in new_config and key in existing:
@@ -491,7 +519,9 @@ class UnifiedConfigManager:
491
519
  "decision_base_url",
492
520
  "decision_model_name",
493
521
  "decision_api_key",
494
- "thinking_mode",
522
+ "agent_type",
523
+ "agent_config_params",
524
+ "default_max_steps",
495
525
  ]
496
526
 
497
527
  for key in config_keys:
@@ -658,7 +688,9 @@ class UnifiedConfigManager:
658
688
  "decision_base_url": config.decision_base_url,
659
689
  "decision_model_name": config.decision_model_name,
660
690
  "decision_api_key": config.decision_api_key,
661
- "thinking_mode": config.thinking_mode,
691
+ "agent_type": config.agent_type,
692
+ "agent_config_params": config.agent_config_params,
693
+ "default_max_steps": config.default_max_steps,
662
694
  }
663
695
 
664
696
 
@@ -0,0 +1,263 @@
1
+ """Device Protocol Adapter for phone_agent integration.
2
+
3
+ This module provides an adapter that bridges DeviceProtocol implementations
4
+ to the interface expected by phone_agent's DeviceFactory.
5
+
6
+ The adapter allows injecting any DeviceProtocol implementation (ADB, Mock, Remote)
7
+ into phone_agent without modifying the third-party code.
8
+
9
+ Example:
10
+ >>> from AutoGLM_GUI.device_adapter import inject_device_protocol
11
+ >>> from AutoGLM_GUI.devices import MockDevice, ADBDevice
12
+ >>>
13
+ >>> # For testing: inject mock device
14
+ >>> mock = MockDevice("mock_001", state_machine)
15
+ >>> inject_device_protocol(lambda _: mock)
16
+ >>>
17
+ >>> # For production: inject ADB device
18
+ >>> devices = {"phone_1": ADBDevice("emulator-5554")}
19
+ >>> inject_device_protocol(lambda device_id: devices[device_id])
20
+ """
21
+
22
+ from typing import Callable
23
+
24
+ import phone_agent.device_factory as device_factory_module
25
+ from AutoGLM_GUI.device_protocol import DeviceProtocol, Screenshot
26
+
27
+
28
+ class DeviceProtocolAdapter:
29
+ """
30
+ Adapter that bridges DeviceProtocol to phone_agent's DeviceFactory interface.
31
+
32
+ This adapter wraps a DeviceProtocol getter function and exposes the same
33
+ interface as phone_agent's DeviceFactory, allowing seamless injection.
34
+
35
+ The adapter handles:
36
+ - Routing device operations to the correct DeviceProtocol instance
37
+ - Converting between DeviceProtocol and DeviceFactory method signatures
38
+ - Managing device_id parameters (phone_agent passes device_id to each method)
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ get_device: Callable[[str | None], DeviceProtocol],
44
+ default_device_id: str | None = None,
45
+ ):
46
+ """
47
+ Initialize the adapter.
48
+
49
+ Args:
50
+ get_device: Function that returns a DeviceProtocol given a device_id.
51
+ If device_id is None, should return a default device.
52
+ default_device_id: Default device ID to use when None is passed.
53
+ """
54
+ self._get_device = get_device
55
+ self._default_device_id = default_device_id
56
+ # For compatibility with code that checks device_type
57
+ self.device_type = "protocol_adapter"
58
+
59
+ def _device(self, device_id: str | None) -> DeviceProtocol:
60
+ """Get device for the given ID."""
61
+ effective_id = device_id or self._default_device_id
62
+ return self._get_device(effective_id)
63
+
64
+ # === Screenshot ===
65
+ def get_screenshot(
66
+ self, device_id: str | None = None, timeout: int = 10
67
+ ) -> Screenshot:
68
+ """Get screenshot from device."""
69
+ return self._device(device_id).get_screenshot(timeout)
70
+
71
+ # === Input Operations ===
72
+ def tap(
73
+ self, x: int, y: int, device_id: str | None = None, delay: float | None = None
74
+ ) -> None:
75
+ """Tap at coordinates."""
76
+ self._device(device_id).tap(x, y, delay)
77
+
78
+ def double_tap(
79
+ self, x: int, y: int, device_id: str | None = None, delay: float | None = None
80
+ ) -> None:
81
+ """Double tap at coordinates."""
82
+ self._device(device_id).double_tap(x, y, delay)
83
+
84
+ def long_press(
85
+ self,
86
+ x: int,
87
+ y: int,
88
+ duration_ms: int = 3000,
89
+ device_id: str | None = None,
90
+ delay: float | None = None,
91
+ ) -> None:
92
+ """Long press at coordinates."""
93
+ self._device(device_id).long_press(x, y, duration_ms, delay)
94
+
95
+ def swipe(
96
+ self,
97
+ start_x: int,
98
+ start_y: int,
99
+ end_x: int,
100
+ end_y: int,
101
+ duration_ms: int | None = None,
102
+ device_id: str | None = None,
103
+ delay: float | None = None,
104
+ ) -> None:
105
+ """Swipe from start to end."""
106
+ self._device(device_id).swipe(
107
+ start_x, start_y, end_x, end_y, duration_ms, delay
108
+ )
109
+
110
+ def type_text(self, text: str, device_id: str | None = None) -> None:
111
+ """Type text."""
112
+ self._device(device_id).type_text(text)
113
+
114
+ def clear_text(self, device_id: str | None = None) -> None:
115
+ """Clear text."""
116
+ self._device(device_id).clear_text()
117
+
118
+ # === Navigation ===
119
+ def back(self, device_id: str | None = None, delay: float | None = None) -> None:
120
+ """Press back button."""
121
+ self._device(device_id).back(delay)
122
+
123
+ def home(self, device_id: str | None = None, delay: float | None = None) -> None:
124
+ """Press home button."""
125
+ self._device(device_id).home(delay)
126
+
127
+ def launch_app(
128
+ self, app_name: str, device_id: str | None = None, delay: float | None = None
129
+ ) -> bool:
130
+ """Launch an app."""
131
+ return self._device(device_id).launch_app(app_name, delay)
132
+
133
+ # === State Query ===
134
+ def get_current_app(self, device_id: str | None = None) -> str:
135
+ """Get current app name."""
136
+ return self._device(device_id).get_current_app()
137
+
138
+ # === Keyboard Management ===
139
+ def detect_and_set_adb_keyboard(self, device_id: str | None = None) -> str:
140
+ """Detect and set keyboard."""
141
+ return self._device(device_id).detect_and_set_adb_keyboard()
142
+
143
+ def restore_keyboard(self, ime: str, device_id: str | None = None) -> None:
144
+ """Restore keyboard."""
145
+ self._device(device_id).restore_keyboard(ime)
146
+
147
+ # === Device Management ===
148
+ def list_devices(self) -> list[str]:
149
+ """
150
+ List connected devices.
151
+
152
+ Note: This is a simplified implementation. For full device listing,
153
+ use ADBDeviceManager.list_devices() directly.
154
+ """
155
+ # This is called by some parts of phone_agent
156
+ # Return the default device if available
157
+ if self._default_device_id:
158
+ return [self._default_device_id]
159
+ return []
160
+
161
+ def get_connection_class(self):
162
+ """Not applicable for protocol adapter."""
163
+ raise NotImplementedError(
164
+ "Protocol adapter does not support get_connection_class. "
165
+ "Use ADBDeviceManager for connection management."
166
+ )
167
+
168
+
169
+ # Store original factory for restoration
170
+ _original_factory = None
171
+
172
+
173
+ def inject_device_protocol(
174
+ get_device: Callable[[str | None], DeviceProtocol],
175
+ default_device_id: str | None = None,
176
+ ) -> DeviceProtocolAdapter:
177
+ """
178
+ Inject a DeviceProtocol implementation into phone_agent.
179
+
180
+ This replaces phone_agent's global _device_factory with an adapter
181
+ that routes all device operations through the provided DeviceProtocol.
182
+
183
+ Args:
184
+ get_device: Function that returns a DeviceProtocol given a device_id.
185
+ default_device_id: Default device ID when None is passed.
186
+
187
+ Returns:
188
+ The adapter instance (for inspection or further configuration).
189
+
190
+ Example:
191
+ >>> # Single mock device
192
+ >>> mock = MockDevice("mock_001", state_machine)
193
+ >>> inject_device_protocol(lambda _: mock)
194
+ >>>
195
+ >>> # Multiple devices
196
+ >>> devices = {
197
+ ... "phone_1": ADBDevice("emulator-5554"),
198
+ ... "phone_2": RemoteDevice("phone_2", "http://remote:8080"),
199
+ ... }
200
+ >>> inject_device_protocol(lambda did: devices.get(did, devices["phone_1"]))
201
+ """
202
+ # TODO: 不应该依赖这种全部变量
203
+ global _original_factory
204
+
205
+ # Save original factory if not already saved
206
+ if _original_factory is None:
207
+ _original_factory = device_factory_module._device_factory
208
+
209
+ # Create and inject adapter
210
+ adapter = DeviceProtocolAdapter(get_device, default_device_id)
211
+ device_factory_module._device_factory = adapter
212
+
213
+ return adapter
214
+
215
+
216
+ def restore_device_factory() -> None:
217
+ """
218
+ Restore the original device factory.
219
+
220
+ Call this after testing to restore normal operation.
221
+ """
222
+ global _original_factory
223
+
224
+ if _original_factory is not None:
225
+ device_factory_module._device_factory = _original_factory
226
+ _original_factory = None
227
+
228
+
229
+ class DeviceProtocolContext:
230
+ """
231
+ Context manager for temporarily injecting a DeviceProtocol.
232
+
233
+ Example:
234
+ >>> with DeviceProtocolContext(lambda _: mock_device):
235
+ ... agent.run("test instruction")
236
+ >>> # Original factory is automatically restored
237
+ """
238
+
239
+ def __init__(
240
+ self,
241
+ get_device: Callable[[str | None], DeviceProtocol],
242
+ default_device_id: str | None = None,
243
+ ):
244
+ """
245
+ Initialize context.
246
+
247
+ Args:
248
+ get_device: Function that returns a DeviceProtocol given a device_id.
249
+ default_device_id: Default device ID when None is passed.
250
+ """
251
+ self._get_device = get_device
252
+ self._default_device_id = default_device_id
253
+ self._original_factory = None
254
+
255
+ def __enter__(self) -> DeviceProtocolAdapter:
256
+ """Enter context and inject adapter."""
257
+ self._original_factory = device_factory_module._device_factory
258
+ return inject_device_protocol(self._get_device, self._default_device_id)
259
+
260
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
261
+ """Exit context and restore original factory."""
262
+ device_factory_module._device_factory = self._original_factory
263
+ return None