xg-cli 1.0__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 (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/mcp/models.py ADDED
@@ -0,0 +1,86 @@
1
+ """Data models shared by the MCP configuration and runtime layers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from typing import Literal, Mapping
8
+
9
+
10
+ McpServerStatus = Literal[
11
+ "disabled",
12
+ "starting",
13
+ "ready",
14
+ "refreshing",
15
+ "unavailable",
16
+ "restarting",
17
+ "stopping",
18
+ ]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class McpServerConfig:
23
+ name: str
24
+ transport: Literal["stdio", "streamable_http"]
25
+ enabled: bool = True
26
+ command: str | None = None
27
+ args: tuple[str, ...] = ()
28
+ cwd: str | None = None
29
+ env: Mapping[str, str] = field(default_factory=dict)
30
+ url: str | None = None
31
+ headers: Mapping[str, str] = field(default_factory=dict)
32
+ startup_timeout: float = 15.0
33
+ request_timeout: float = 120.0
34
+ shutdown_timeout: float = 5.0
35
+ max_output_chars: int = 20_000
36
+ hitl: Literal["default", "never", "confirm", "always"] = "default"
37
+ tool_overrides: Mapping[str, str] = field(default_factory=dict)
38
+ max_tools: int = 256
39
+ max_resources: int = 512
40
+ max_message_bytes: int = 2_097_152
41
+ resource_max_chars: int = 32_000
42
+ log_lines: int = 200
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class McpToolSpec:
47
+ server: str
48
+ remote_name: str
49
+ exposed_name: str
50
+ description: str
51
+ input_schema: dict
52
+ annotations: dict = field(default_factory=dict)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class McpResource:
57
+ server: str
58
+ uri: str
59
+ name: str
60
+ description: str = ""
61
+ mime_type: str | None = None
62
+ size: int | None = None
63
+ metadata: dict = field(default_factory=dict)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class McpServerSnapshot:
68
+ name: str
69
+ transport: str
70
+ status: McpServerStatus
71
+ protocol_version: str | None = None
72
+ tool_count: int = 0
73
+ resource_count: int = 0
74
+ last_error: str = ""
75
+ started_at: datetime | None = None
76
+ last_refresh_at: datetime | None = None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class McpEvent:
81
+ kind: str
82
+ server: str = ""
83
+ text: str = ""
84
+ tool_count: int | None = None
85
+ resource_count: int | None = None
86
+
xg/mcp/protocol.py ADDED
@@ -0,0 +1,62 @@
1
+ """MCP JSON-RPC constants, validation and public exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ SUPPORTED_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
9
+ DEFAULT_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]
10
+
11
+
12
+ class McpError(Exception):
13
+ """Base MCP error with a user-safe message."""
14
+
15
+
16
+ class McpConfigError(McpError):
17
+ pass
18
+
19
+
20
+ class McpProtocolError(McpError):
21
+ pass
22
+
23
+
24
+ class McpUnavailableError(McpError):
25
+ pass
26
+
27
+
28
+ class McpRequestError(McpError):
29
+ def __init__(self, code: int | str, message: str, data: Any = None) -> None:
30
+ self.code = code
31
+ self.data = data
32
+ super().__init__(f"MCP error {code}: {message}")
33
+
34
+
35
+ def request_message(request_id: int, method: str, params: dict | None = None) -> dict:
36
+ message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method}
37
+ if params is not None:
38
+ message["params"] = params
39
+ return message
40
+
41
+
42
+ def notification_message(method: str, params: dict | None = None) -> dict:
43
+ message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
44
+ if params is not None:
45
+ message["params"] = params
46
+ return message
47
+
48
+
49
+ def parse_result(message: dict, expected_id: int | str | None = None) -> dict:
50
+ if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
51
+ raise McpProtocolError("非法 JSON-RPC envelope")
52
+ if expected_id is not None and message.get("id") != expected_id:
53
+ raise McpProtocolError("JSON-RPC response id 不匹配")
54
+ error = message.get("error")
55
+ if isinstance(error, dict):
56
+ raise McpRequestError(error.get("code", -32000), str(error.get("message", "unknown error")), error.get("data"))
57
+ result = message.get("result", {})
58
+ if result is None:
59
+ return {}
60
+ if not isinstance(result, dict):
61
+ raise McpProtocolError("JSON-RPC result 必须是对象")
62
+ return result
xg/mcp/resources.py ADDED
@@ -0,0 +1,72 @@
1
+ """MCP resource decoding and explicit @server:uri reference parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ from dataclasses import dataclass
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+
11
+ _REFERENCE_RE = re.compile(
12
+ r"@(?P<server>[A-Za-z0-9_-]{1,32}):(?P<uri>[^\s,。;!?,;!?\)\]\}]+)"
13
+ )
14
+ _TRAILING = ".,;!?,。;!?)]}"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ResourceReference:
19
+ server: str
20
+ uri: str
21
+ token: str
22
+
23
+
24
+ def find_resource_references(text: str, limit: int = 8) -> list[ResourceReference]:
25
+ found: list[ResourceReference] = []
26
+ for match in _REFERENCE_RE.finditer(text):
27
+ uri = match.group("uri").rstrip(_TRAILING)
28
+ if not uri or ":" not in uri:
29
+ continue
30
+ token = f"@{match.group('server')}:{uri}"
31
+ found.append(ResourceReference(match.group("server"), uri, token))
32
+ if len(found) >= limit:
33
+ break
34
+ return found
35
+
36
+
37
+ def decode_resource_contents(result: dict, max_chars: int) -> tuple[str, bool]:
38
+ contents = result.get("contents", [])
39
+ if not isinstance(contents, list):
40
+ return "", False
41
+ parts: list[str] = []
42
+ binary_seen = False
43
+ for item in contents:
44
+ if not isinstance(item, dict):
45
+ continue
46
+ if isinstance(item.get("text"), str):
47
+ parts.append(item["text"])
48
+ elif isinstance(item.get("blob"), str):
49
+ binary_seen = True
50
+ try:
51
+ size = len(base64.b64decode(item["blob"], validate=False))
52
+ except Exception:
53
+ size = len(item["blob"])
54
+ parts.append(f"[二进制 MCP resource,约 {size} bytes,未内联]")
55
+ text = "\n\n".join(parts)
56
+ truncated = len(text) > max_chars
57
+ if truncated:
58
+ text = text[:max_chars] + f"\n... (资源已截断,原始长度 {len(text)} 字符)"
59
+ return text, truncated or binary_seen
60
+
61
+
62
+ def redact_uri(uri: str) -> str:
63
+ try:
64
+ parsed = urlsplit(uri)
65
+ except ValueError:
66
+ return uri.split("?", 1)[0]
67
+ if not parsed.scheme:
68
+ return uri.split("?", 1)[0]
69
+ host = parsed.hostname or ""
70
+ if parsed.port:
71
+ host = f"{host}:{parsed.port}"
72
+ return urlunsplit((parsed.scheme, host, parsed.path, "", ""))
xg/mcp/schema.py ADDED
@@ -0,0 +1,137 @@
1
+ """Sanitize MCP input schemas for OpenAI-compatible tool definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import re
7
+ from typing import Any
8
+
9
+
10
+ _NAME_RE = re.compile(r"[^A-Za-z0-9_-]+")
11
+ _ALLOWED = {
12
+ "type", "properties", "required", "items", "description", "enum", "default",
13
+ "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems",
14
+ "additionalProperties", "format",
15
+ }
16
+
17
+
18
+ def exposed_tool_name(server: str, remote_name: str, max_length: int = 128) -> str:
19
+ clean = _NAME_RE.sub("_", remote_name).strip("_") or "tool"
20
+ return f"mcp__{server}__{clean}"[:max_length]
21
+
22
+
23
+ def sanitize_description(value: Any, max_chars: int = 4000) -> str:
24
+ text = " ".join(str(value or "").split())
25
+ if len(text) <= max_chars:
26
+ return text
27
+ return text[: max(0, max_chars - 8)] + "…[截断]"
28
+
29
+
30
+ def sanitize_schema(schema: Any, *, max_depth: int = 8, max_properties: int = 128) -> tuple[dict, list[str]]:
31
+ warnings: list[str] = []
32
+ root = copy.deepcopy(schema) if isinstance(schema, dict) else {}
33
+ definitions = root.get("$defs") or root.get("definitions") or {}
34
+ resolving: set[str] = set()
35
+
36
+ def resolve_ref(ref: str) -> dict | None:
37
+ if not ref.startswith("#/") or ref in resolving:
38
+ return None
39
+ node: Any = root
40
+ try:
41
+ for part in ref[2:].split("/"):
42
+ part = part.replace("~1", "/").replace("~0", "~")
43
+ node = node[part]
44
+ except (KeyError, TypeError):
45
+ return None
46
+ if not isinstance(node, dict):
47
+ return None
48
+ resolving.add(ref)
49
+ try:
50
+ return clean(node, 1)
51
+ finally:
52
+ resolving.discard(ref)
53
+
54
+ def merge_all_of(items: list, depth: int) -> dict:
55
+ merged: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
56
+ for item in items:
57
+ part = clean(item, depth + 1)
58
+ if part.get("type") != "object":
59
+ warnings.append("allOf 含非 object,已降级")
60
+ continue
61
+ merged["properties"].update(part.get("properties", {}))
62
+ merged["required"].extend(part.get("required", []))
63
+ merged["required"] = list(dict.fromkeys(merged["required"]))
64
+ return merged
65
+
66
+ def clean(node: Any, depth: int) -> dict:
67
+ if depth > max_depth or not isinstance(node, dict):
68
+ warnings.append("schema 深度超限,已降级为 string")
69
+ return {"type": "string"}
70
+ if isinstance(node.get("$ref"), str):
71
+ resolved = resolve_ref(node["$ref"])
72
+ if resolved is None:
73
+ warnings.append(f"无法解析 $ref: {node['$ref']}")
74
+ return {"type": "string", "description": "原 schema reference 无法展开"}
75
+ return resolved
76
+ if isinstance(node.get("allOf"), list):
77
+ merged = merge_all_of(node["allOf"], depth)
78
+ if node.get("description"):
79
+ merged["description"] = sanitize_description(node["description"])
80
+ return merged
81
+ union = node.get("anyOf") or node.get("oneOf")
82
+ if isinstance(union, list) and union:
83
+ options = [clean(item, depth + 1) for item in union if isinstance(item, dict)]
84
+ non_null = [item for item in options if item.get("type") != "null"]
85
+ if len(non_null) == 1:
86
+ chosen = non_null[0]
87
+ if node.get("description"):
88
+ chosen["description"] = sanitize_description(node["description"])
89
+ return chosen
90
+ types = {str(item.get("type")) for item in non_null if item.get("type")}
91
+ if len(types) == 1 and non_null:
92
+ return non_null[0]
93
+ warnings.append("anyOf/oneOf 无法无损表达,已降级为 string")
94
+ return {"type": "string", "description": sanitize_description(node.get("description") or "联合类型参数")}
95
+
96
+ inferred = node.get("type")
97
+ if isinstance(inferred, list):
98
+ values = [value for value in inferred if value != "null"]
99
+ inferred = values[0] if len(values) == 1 else "string"
100
+ if not isinstance(inferred, str):
101
+ inferred = "object" if isinstance(node.get("properties"), dict) else "string"
102
+ if inferred not in {"object", "array", "string", "integer", "number", "boolean", "null"}:
103
+ inferred = "string"
104
+ out: dict[str, Any] = {"type": inferred}
105
+ for key in _ALLOWED - {"type", "properties", "required", "items", "description", "enum"}:
106
+ if key in node and isinstance(node[key], (str, int, float, bool)):
107
+ out[key] = node[key]
108
+ if node.get("description"):
109
+ out["description"] = sanitize_description(node["description"])
110
+ if isinstance(node.get("enum"), list):
111
+ out["enum"] = node["enum"][:100]
112
+ if len(node["enum"]) > 100:
113
+ warnings.append("enum 超过 100 项,已截断")
114
+ if inferred == "object":
115
+ props = node.get("properties", {})
116
+ if not isinstance(props, dict):
117
+ props = {}
118
+ selected = list(props.items())[:max_properties]
119
+ if len(props) > max_properties:
120
+ warnings.append("properties 超过上限,已截断")
121
+ out["properties"] = {str(name): clean(value, depth + 1) for name, value in selected}
122
+ required = node.get("required", [])
123
+ if isinstance(required, list):
124
+ out["required"] = [str(name) for name in required if str(name) in out["properties"]]
125
+ if "additionalProperties" in node and isinstance(node["additionalProperties"], bool):
126
+ out["additionalProperties"] = node["additionalProperties"]
127
+ elif inferred == "array":
128
+ out["items"] = clean(node.get("items", {}), depth + 1)
129
+ return out
130
+
131
+ cleaned = clean(root, 0)
132
+ if cleaned.get("type") != "object":
133
+ warnings.append("工具 inputSchema 顶层不是 object,已包装为空 object")
134
+ cleaned = {"type": "object", "properties": {}}
135
+ cleaned.setdefault("properties", {})
136
+ return cleaned, warnings
137
+
xg/mcp/stdio.py ADDED
@@ -0,0 +1,210 @@
1
+ """MCP stdio transport implemented with asyncio subprocesses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from contextlib import suppress
9
+ from typing import Any
10
+
11
+ from xg.mcp.models import McpServerConfig
12
+ from xg.mcp.protocol import McpProtocolError, McpRequestError, McpUnavailableError, notification_message, request_message
13
+ from xg.mcp.transport import McpTransport
14
+
15
+
16
+ class StdioTransport(McpTransport):
17
+ def __init__(self, config: McpServerConfig) -> None:
18
+ super().__init__(log_lines=config.log_lines)
19
+ self.config = config
20
+ self.process: asyncio.subprocess.Process | None = None
21
+ self._reader_task: asyncio.Task | None = None
22
+ self._stderr_task: asyncio.Task | None = None
23
+ self._wait_task: asyncio.Task | None = None
24
+ self._pending: dict[int, asyncio.Future[dict]] = {}
25
+ self._next_id = 1
26
+ self._write_lock = asyncio.Lock()
27
+ self._closing = False
28
+ self._disconnect_notified = False
29
+
30
+ async def connect(self) -> None:
31
+ if self.process is not None and self.process.returncode is None:
32
+ return
33
+ if not self.config.command:
34
+ raise McpUnavailableError("stdio Server 缺少 command")
35
+ child_env = os.environ.copy()
36
+ child_env.update(self.config.env)
37
+ try:
38
+ self.process = await asyncio.create_subprocess_exec(
39
+ self.config.command,
40
+ *self.config.args,
41
+ cwd=self.config.cwd,
42
+ env=child_env,
43
+ stdin=asyncio.subprocess.PIPE,
44
+ stdout=asyncio.subprocess.PIPE,
45
+ stderr=asyncio.subprocess.PIPE,
46
+ limit=self.config.max_message_bytes + 1,
47
+ )
48
+ except (OSError, ValueError) as exc:
49
+ raise McpUnavailableError(f"无法启动 stdio Server: {exc}") from exc
50
+ self._closing = False
51
+ self._disconnect_notified = False
52
+ self._reader_task = asyncio.create_task(self._read_stdout(), name=f"mcp-{self.config.name}-stdout")
53
+ self._stderr_task = asyncio.create_task(self._read_stderr(), name=f"mcp-{self.config.name}-stderr")
54
+ self._wait_task = asyncio.create_task(self._watch_process(), name=f"mcp-{self.config.name}-wait")
55
+
56
+ async def _send(self, message: dict) -> None:
57
+ process = self.process
58
+ if process is None or process.returncode is not None or process.stdin is None:
59
+ raise McpUnavailableError("MCP stdio Server 未连接")
60
+ payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n"
61
+ if len(payload) > self.config.max_message_bytes:
62
+ raise McpProtocolError("JSON-RPC 请求超过消息上限")
63
+ async with self._write_lock:
64
+ process.stdin.write(payload)
65
+ try:
66
+ await process.stdin.drain()
67
+ except (BrokenPipeError, ConnectionResetError) as exc:
68
+ raise McpUnavailableError("MCP stdio 连接已断开") from exc
69
+
70
+ async def request(self, method: str, params: dict | None = None) -> dict:
71
+ request_id = self._next_id
72
+ self._next_id += 1
73
+ future = asyncio.get_running_loop().create_future()
74
+ self._pending[request_id] = future
75
+ try:
76
+ await self._send(request_message(request_id, method, params))
77
+ message = await asyncio.wait_for(future, timeout=self.config.request_timeout)
78
+ except asyncio.TimeoutError as exc:
79
+ raise McpUnavailableError(f"MCP 请求超时: {method}") from exc
80
+ finally:
81
+ self._pending.pop(request_id, None)
82
+ error = message.get("error")
83
+ if isinstance(error, dict):
84
+ raise McpRequestError(error.get("code", -32000), str(error.get("message", "unknown error")), error.get("data"))
85
+ result = message.get("result", {})
86
+ if result is None:
87
+ return {}
88
+ if not isinstance(result, dict):
89
+ raise McpProtocolError("JSON-RPC result 必须是对象")
90
+ return result
91
+
92
+ async def notify(self, method: str, params: dict | None = None) -> None:
93
+ await self._send(notification_message(method, params))
94
+
95
+ async def _read_stdout(self) -> None:
96
+ process = self.process
97
+ if process is None or process.stdout is None:
98
+ return
99
+ try:
100
+ while not self._closing:
101
+ line = await process.stdout.readline()
102
+ if not line:
103
+ break
104
+ if len(line) > self.config.max_message_bytes:
105
+ raise McpProtocolError("MCP stdio 消息超过上限")
106
+ try:
107
+ message = json.loads(line.decode("utf-8"))
108
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
109
+ self.log(f"protocol error: invalid JSON ({exc})")
110
+ continue
111
+ if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
112
+ self.log("protocol error: invalid JSON-RPC envelope")
113
+ continue
114
+ if "id" in message and ("result" in message or "error" in message):
115
+ future = self._pending.get(message["id"])
116
+ if future is not None and not future.done():
117
+ future.set_result(message)
118
+ continue
119
+ method = message.get("method")
120
+ if isinstance(method, str) and "id" not in message:
121
+ await self.dispatch_notification(method, message.get("params"))
122
+ elif isinstance(method, str) and "id" in message:
123
+ if method == "ping":
124
+ await self._send({"jsonrpc": "2.0", "id": message["id"], "result": {}})
125
+ else:
126
+ await self._send({
127
+ "jsonrpc": "2.0",
128
+ "id": message["id"],
129
+ "error": {"code": -32601, "message": "Method not found"},
130
+ })
131
+ except asyncio.CancelledError:
132
+ raise
133
+ except Exception as exc:
134
+ self.log(f"protocol error: {type(exc).__name__}: {exc}")
135
+ finally:
136
+ if not self._closing:
137
+ error = McpUnavailableError("MCP stdio 连接已关闭")
138
+ await self._notify_disconnect(error)
139
+
140
+ async def _read_stderr(self) -> None:
141
+ process = self.process
142
+ if process is None or process.stderr is None:
143
+ return
144
+ try:
145
+ while not self._closing:
146
+ line = await process.stderr.readline()
147
+ if not line:
148
+ break
149
+ self.log(line.decode("utf-8", errors="replace"))
150
+ except asyncio.CancelledError:
151
+ raise
152
+
153
+ async def _watch_process(self) -> None:
154
+ process = self.process
155
+ if process is None:
156
+ return
157
+ code = await process.wait()
158
+ if not self._closing:
159
+ self.log(f"process exited with code {code}")
160
+ error = McpUnavailableError(f"MCP Server 已退出(code={code})")
161
+ await self._notify_disconnect(error)
162
+
163
+ async def _notify_disconnect(self, error: Exception) -> None:
164
+ """Notify the owner once when the child connection is lost.
165
+
166
+ Both stdout EOF and process.wait() observe the same child exit. The
167
+ first observer owns failure propagation; the other observer must not
168
+ emit a duplicate lifecycle event.
169
+ """
170
+ if self._closing or self._disconnect_notified:
171
+ return
172
+ self._disconnect_notified = True
173
+ self._fail_pending(error)
174
+ await self.dispatch_disconnect(error)
175
+
176
+ def _fail_pending(self, error: Exception) -> None:
177
+ for future in list(self._pending.values()):
178
+ if not future.done():
179
+ future.set_exception(error)
180
+ self._pending.clear()
181
+
182
+ async def close(self) -> None:
183
+ if self._closing:
184
+ return
185
+ self._closing = True
186
+ self._fail_pending(McpUnavailableError("MCP Server 正在关闭"))
187
+ process = self.process
188
+ if process is not None and process.stdin is not None:
189
+ process.stdin.close()
190
+ with suppress(Exception):
191
+ await process.stdin.wait_closed()
192
+ if process is not None and process.returncode is None:
193
+ try:
194
+ await asyncio.wait_for(process.wait(), timeout=self.config.shutdown_timeout)
195
+ except asyncio.TimeoutError:
196
+ process.terminate()
197
+ try:
198
+ await asyncio.wait_for(process.wait(), timeout=self.config.shutdown_timeout)
199
+ except asyncio.TimeoutError:
200
+ process.kill()
201
+ await process.wait()
202
+ current = asyncio.current_task()
203
+ for task in (self._reader_task, self._stderr_task, self._wait_task):
204
+ if task is not None and task is not current and not task.done():
205
+ task.cancel()
206
+ for task in (self._reader_task, self._stderr_task, self._wait_task):
207
+ if task is not None and task is not current:
208
+ with suppress(asyncio.CancelledError, Exception):
209
+ await task
210
+ self.process = None
xg/mcp/transport.py ADDED
@@ -0,0 +1,66 @@
1
+ """Transport abstraction and common logging helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections import deque
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Any
9
+
10
+
11
+ NotificationHandler = Callable[[str, dict], Awaitable[None] | None]
12
+ DisconnectHandler = Callable[[Exception], Awaitable[None] | None]
13
+
14
+
15
+ class McpTransport(ABC):
16
+ def __init__(self, *, log_lines: int = 200) -> None:
17
+ self._logs: deque[str] = deque(maxlen=max(1, log_lines))
18
+ self.notification_handler: NotificationHandler | None = None
19
+ self.disconnect_handler: DisconnectHandler | None = None
20
+ self.protocol_version: str | None = None
21
+
22
+ def set_protocol_version(self, version: str) -> None:
23
+ self.protocol_version = version
24
+
25
+ def set_notification_handler(self, handler: NotificationHandler | None) -> None:
26
+ self.notification_handler = handler
27
+
28
+ def set_disconnect_handler(self, handler: DisconnectHandler | None) -> None:
29
+ self.disconnect_handler = handler
30
+
31
+ async def dispatch_disconnect(self, error: Exception) -> None:
32
+ handler = self.disconnect_handler
33
+ if handler is None:
34
+ return
35
+ result = handler(error)
36
+ if result is not None:
37
+ await result
38
+
39
+ async def dispatch_notification(self, method: str, params: dict | None) -> None:
40
+ handler = self.notification_handler
41
+ if handler is None:
42
+ return
43
+ result = handler(method, params or {})
44
+ if result is not None:
45
+ await result
46
+
47
+ def log(self, message: str) -> None:
48
+ self._logs.append(str(message).replace("\r", "").rstrip("\n"))
49
+
50
+ def recent_logs(self, limit: int = 50) -> list[str]:
51
+ return list(self._logs)[-max(1, limit):]
52
+
53
+ @abstractmethod
54
+ async def connect(self) -> None: ...
55
+
56
+ @abstractmethod
57
+ async def request(self, method: str, params: dict | None = None) -> dict: ...
58
+
59
+ @abstractmethod
60
+ async def notify(self, method: str, params: dict | None = None) -> None: ...
61
+
62
+ async def start_notifications(self) -> None:
63
+ """Start an optional server-initiated notification stream."""
64
+
65
+ @abstractmethod
66
+ async def close(self) -> None: ...
xg/memory/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """第五期:项目记忆、长期记忆与上下文压缩。"""
2
+
3
+ from xg.memory.context import CompressionResult, ConversationContext
4
+ from xg.memory.manager import MemoryManager, SharedSection
5
+ from xg.memory.models import MemoryEntry
6
+ from xg.memory.store import SQLiteMemoryStore
7
+
8
+ __all__ = [
9
+ "CompressionResult",
10
+ "ConversationContext",
11
+ "MemoryEntry",
12
+ "MemoryManager",
13
+ "SharedSection",
14
+ "SQLiteMemoryStore",
15
+ ]